diff --git a/src/highdicom/sr/templates/__init__.py b/src/highdicom/sr/templates/__init__.py new file mode 100644 index 00000000..2bf1fb5e --- /dev/null +++ b/src/highdicom/sr/templates/__init__.py @@ -0,0 +1,147 @@ +"""Classes implementing structured reporting templates""" +from highdicom.sr.templates.common import ( + DeviceObserverIdentifyingAttributes, + ObserverContext, + PersonObserverIdentifyingAttributes, + AlgorithmIdentification, + LanguageOfContentItemAndDescendants, + ObservationContext, + SubjectContext, + SubjectContextDevice, + SubjectContextFetus, + SubjectContextSpecimen, + AgeUnit, + PressureUnit, + LanguageOfValue +) +from highdicom.sr.templates.tid1500 import ( + TrackingIdentifier, + TimePointContext, + MeasurementStatisticalProperties, + NormalRangeProperties, + MeasurementProperties, + QualitativeEvaluation, + Measurement, + MeasurementsAndQualitativeEvaluations, + PlanarROIMeasurementsAndQualitativeEvaluations, + VolumetricROIMeasurementsAndQualitativeEvaluations, + ImageLibraryEntryDescriptors, + MeasurementReport, + ImageLibraryEntry, + ImageLibrary +) +from highdicom.sr.templates.tid2000 import ( + EquivalentMeaningsOfConceptNameText, + EquivalentMeaningsOfConceptNameCode, + ReportNarrativeCode, + ReportNarrativeText, + DiagnosticImagingReportHeading, + BasicDiagnosticImagingReport +) +from highdicom.sr.templates.tid3700 import ( + ECGWaveFormInformation, + ECGMeasurementSource, + QTcIntervalGlobal, + NumberOfEctopicBeats, + ECGGlobalMeasurements, + ECGLeadMeasurements, + QuantitativeAnalysis, + IndicationsForProcedure, + PatientCharacteristicsForECG, + PriorECGStudy, + ECGFinding, + ECGQualitativeAnalysis, + SummaryECG, + ECGReport +) +from highdicom.sr.templates.tid3802 import ( + Therapy, + ProblemProperties, + ProblemList, + SocialHistory, + ProcedureProperties, + PastSurgicalHistory, + RelevantDiagnosticTestsAndOrLaboratoryData, + MedicationTypeText, + MedicationTypeCode, + HistoryOfMedicationUse, + FamilyHistoryOfClinicalFinding, + HistoryOfFamilyMemberDiseases, + MedicalDeviceUse, + HistoryOfMedicalDeviceUse, + CardiovascularPatientHistory +) + +__all__ = [ + # Common + 'DeviceObserverIdentifyingAttributes', + 'ObserverContext', + 'PersonObserverIdentifyingAttributes', + 'AlgorithmIdentification', + 'LanguageOfContentItemAndDescendants', + 'ObservationContext', + 'SubjectContext', + 'SubjectContextDevice', + 'SubjectContextFetus', + 'SubjectContextSpecimen', + 'AgeUnit', + 'PressureUnit', + 'LanguageOfValue', + + # TID 1500 + 'TrackingIdentifier', + 'TimePointContext', + 'MeasurementStatisticalProperties', + 'NormalRangeProperties', + 'MeasurementProperties', + 'QualitativeEvaluation', + 'Measurement', + 'MeasurementsAndQualitativeEvaluations', + 'PlanarROIMeasurementsAndQualitativeEvaluations', + 'VolumetricROIMeasurementsAndQualitativeEvaluations', + 'ImageLibraryEntryDescriptors', + 'MeasurementReport', + 'ImageLibraryEntry', + 'ImageLibrary', + + # TID 2000 + 'EquivalentMeaningsOfConceptNameText', + 'EquivalentMeaningsOfConceptNameCode', + 'ReportNarrativeCode', + 'ReportNarrativeText', + 'DiagnosticImagingReportHeading', + 'BasicDiagnosticImagingReport', + + # TID 3700 + 'ECGWaveFormInformation', + 'ECGMeasurementSource', + 'QTcIntervalGlobal', + 'NumberOfEctopicBeats', + 'ECGGlobalMeasurements', + 'ECGLeadMeasurements', + 'QuantitativeAnalysis', + 'IndicationsForProcedure', + 'PatientCharacteristicsForECG', + 'PriorECGStudy', + 'ECGFinding', + 'ECGQualitativeAnalysis', + 'SummaryECG', + 'ECGReport', + + # TID 3802 + 'Therapy', + 'ProblemProperties', + 'ProblemList', + 'SocialHistory', + 'ProcedureProperties', + 'PastSurgicalHistory', + 'RelevantDiagnosticTestsAndOrLaboratoryData', + 'MedicationTypeText', + 'MedicationTypeCode', + 'HistoryOfMedicationUse', + 'FamilyHistoryOfClinicalFinding', + 'HistoryOfFamilyMemberDiseases', + 'MedicalDeviceUse', + 'HistoryOfMedicalDeviceUse', + 'CardiovascularPatientHistory' +] diff --git a/src/highdicom/sr/templates/common.py b/src/highdicom/sr/templates/common.py new file mode 100644 index 00000000..47feaeea --- /dev/null +++ b/src/highdicom/sr/templates/common.py @@ -0,0 +1,1406 @@ +"""Sub-templates common to multiple root-level templates""" +from abc import ABC +import logging +from typing import List, Optional, Self, Sequence, TypedDict, Union + +from pydicom import Dataset +from highdicom.sr.value_types import ( + Code, + CodeContentItem, + CodedConcept, + ContentSequence, + NumContentItem, + TextContentItem, + PnameContentItem, + RelationshipTypeValues, + UIDRefContentItem +) +from highdicom.sr.content import ContentItem +from pydicom.sr.codedict import codes +from copy import deepcopy +from highdicom.uid import UID + +logger = logging.getLogger(__name__) + + +class Template(ContentSequence): + + """Abstract base class for a DICOM SR template.""" + + def __init__( + self, + items: Sequence[ContentItem] | None = None, + is_root: bool = False + ) -> None: + """ + + Parameters + ---------- + items: Sequence[ContentItem], optional + content items + is_root: bool + Whether this template exists at the root of the SR document + content tree. + + """ + super().__init__(items, is_root=is_root) + + +class Units(TypedDict): + value: Union[float, None] + code_value: str + code_meaning: str + + +class CIDUnits(ABC): + name: str + coding_scheme_designator: str = "UCUM" + units: List[Units] + + def add_items(self, content: ContentSequence) -> None: + """ Adds units as NumContentItems to a content sequence """ + + for unit in self.units: + if unit["value"] is not None: + item = NumContentItem( + name=self.name, + value=unit["value"], + unit=CodedConcept( + value=unit["code_value"], + meaning=unit["code_meaning"], + scheme_designator=self.coding_scheme_designator + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(item) + + +class AgeUnit(CIDUnits): + name = codes.DCM.SubjectAge + + def __init__( + self, + year: Union[int, None] = None, + month: Union[int, None] = None, + week: Union[int, None] = None, + day: Union[int, None] = None, + hour: Union[int, None] = None, + minute: Union[int, None] = None + ) -> None: + self.units = [ + {'value': year, 'code_value': "a", 'code_meaning': "year"}, + {'value': month, 'code_value': "mo", 'code_meaning': "month"}, + {'value': week, 'code_value': "wk", 'code_meaning': "week"}, + {'value': day, 'code_value': "d", 'code_meaning': "day"}, + {'value': hour, 'code_value': "h", 'code_meaning': "hour"}, + {'value': minute, 'code_value': "min", 'code_meaning': "minute"} + ] + + +class PressureUnit(CIDUnits): + def __init__( + self, + mmHg: Union[int, None] = None, + kPa: Union[int, None] = None + ) -> None: + self.units = [ + {'value': mmHg, 'code_value': "mm[Hg]", 'code_meaning': "mmHg"}, + {'value': kPa, 'code_value': "kPa", 'code_meaning': "kPa"} + ] + + +class AlgorithmIdentification(Template): + + """:dcm:`TID 4019 ` + Algorithm Identification""" + + def __init__( + self, + name: str, + version: str, + parameters: Sequence[str] | None = None + ) -> None: + """ + + Parameters + ---------- + name: str + name of the algorithm + version: str + version of the algorithm + parameters: Union[Sequence[str], None], optional + parameters of the algorithm + + """ + super().__init__() + name_item = TextContentItem( + name=CodedConcept( + value='111001', + meaning='Algorithm Name', + scheme_designator='DCM' + ), + value=name, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + self.append(name_item) + version_item = TextContentItem( + name=CodedConcept( + value='111003', + meaning='Algorithm Version', + scheme_designator='DCM' + ), + value=version, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + self.append(version_item) + if parameters is not None: + for param in parameters: + parameter_item = TextContentItem( + name=CodedConcept( + value='111002', + meaning='Algorithm Parameters', + scheme_designator='DCM' + ), + value=param, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + self.append(parameter_item) + + +class LanguageOfContentItemAndDescendants(Template): + + """:dcm:`TID 1204 ` + Language of Content Item and Descendants""" + + def __init__( + self, + language: CodedConcept + ) -> None: + """ + + Parameters + ---------- + language: highdicom.sr.CodedConcept + language used for content items included in report + + """ + super().__init__() + language_item = CodeContentItem( + name=CodedConcept( + value='121049', + meaning='Language of Content Item and Descendants', + scheme_designator='DCM', + ), + value=language, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + self.append(language_item) + + +class PersonObserverIdentifyingAttributes(Template): + + """:dcm:`TID 1003 ` + Person Observer Identifying Attributes""" + + def __init__( + self, + name: str, + login_name: str | None = None, + organization_name: str | None = None, + role_in_organization: CodedConcept | Code | None = None, + role_in_procedure: CodedConcept | Code | None = None + ): + """ + + Parameters + ---------- + name: str + name of the person + login_name: Union[str, None], optional + login name of the person + organization_name: Union[str, None], optional + name of the person's organization + role_in_organization: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + role of the person within the organization + role_in_procedure: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + role of the person in the reported procedure + + """ # noqa: E501 + super().__init__() + name_item = PnameContentItem( + name=CodedConcept( + value='121008', + meaning='Person Observer Name', + scheme_designator='DCM', + ), + value=name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(name_item) + if login_name is not None: + login_name_item = TextContentItem( + name=CodedConcept( + value='128774', + meaning='Person Observer\'s Login Name', + scheme_designator='DCM', + ), + value=login_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(login_name_item) + if organization_name is not None: + organization_name_item = TextContentItem( + name=CodedConcept( + value='121009', + meaning='Person Observer\'s Organization Name', + scheme_designator='DCM', + ), + value=organization_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(organization_name_item) + if role_in_organization is not None: + role_in_organization_item = CodeContentItem( + name=CodedConcept( + value='121010', + meaning='Person Observer\'s Role in the Organization', + scheme_designator='DCM', + ), + value=role_in_organization, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(role_in_organization_item) + if role_in_procedure is not None: + role_in_procedure_item = CodeContentItem( + name=CodedConcept( + value='121011', + meaning='Person Observer\'s Role in this Procedure', + scheme_designator='DCM', + ), + value=role_in_procedure, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(role_in_procedure_item) + + @property + def name(self) -> str: + """str: name of the person""" + return self[0].value + + @property + def login_name(self) -> str | None: + """Union[str, None]: login name of the person""" + matches = [ + item for item in self + if item.name == codes.DCM.PersonObserverLoginName + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Login Name" content item ' + 'in "Person Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def organization_name(self) -> str | None: + """Union[str, None]: name of the person's organization""" + matches = [ + item for item in self + if item.name == codes.DCM.PersonObserverOrganizationName + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Organization Name" content item ' + 'in "Person Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def role_in_organization(self) -> str | None: + """Union[str, None]: role of the person in the organization""" + matches = [ + item for item in self + if item.name == codes.DCM.PersonObserverRoleInTheOrganization + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Role in Organization" content item ' + 'in "Person Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def role_in_procedure(self) -> str | None: + """Union[str, None]: role of the person in the procedure""" + matches = [ + item for item in self + if item.name == codes.DCM.PersonObserverRoleInThisProcedure + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Role in Procedure" content item ' + 'in "Person Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @classmethod + def from_sequence( + cls, + sequence: Sequence[Dataset], + is_root: bool = False + ) -> Self: + """Construct object from a sequence of datasets. + + Parameters + ---------- + sequence: Sequence[pydicom.dataset.Dataset] + Datasets representing SR Content Items of template + TID 1003 "Person Observer Identifying Attributes" + is_root: bool, optional + Whether the sequence is used to contain SR Content Items that are + intended to be added to an SR document at the root of the document + content tree + + Returns + ------- + highdicom.sr.PersonObserverIdentifyingAttributes + Content Sequence containing SR Content Items + + """ + attr_codes = [ + ('name', codes.DCM.PersonObserverName), + ('login_name', codes.DCM.PersonObserverLoginName), + ('organization_name', + codes.DCM.PersonObserverOrganizationName), + ('role_in_organization', + codes.DCM.PersonObserverRoleInTheOrganization), + ('role_in_procedure', + codes.DCM.PersonObserverRoleInThisProcedure), + ] + kwargs = {} + for dataset in sequence: + dataset_copy = deepcopy(dataset) + content_item = ContentItem._from_dataset_derived(dataset_copy) + for param, name in attr_codes: + if content_item.name == name: + kwargs[param] = content_item.value + return cls(**kwargs) + + +class DeviceObserverIdentifyingAttributes(Template): + + """:dcm:`TID 1004 ` + Device Observer Identifying Attributes + """ + + def __init__( + self, + uid: str, + name: str | None = None, + manufacturer_name: str | None = None, + model_name: str | None = None, + serial_number: str | None = None, + physical_location: str | None = None, + role_in_procedure: Code | CodedConcept | None = None + ): + """ + + Parameters + ---------- + uid: str + device UID + name: Union[str, None], optional + name of device + manufacturer_name: Union[str, None], optional + name of device's manufacturer + model_name: Union[str, None], optional + name of the device's model + serial_number: Union[str, None], optional + serial number of the device + physical_location: Union[str, None], optional + physical location of the device during the procedure + role_in_procedure: Union[pydicom.sr.coding.Code, highdicom.sr.CodedConcept, None], optional + role of the device in the reported procedure + + """ # noqa: E501 + super().__init__() + device_observer_item = UIDRefContentItem( + name=CodedConcept( + value='121012', + meaning='Device Observer UID', + scheme_designator='DCM', + ), + value=uid, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(device_observer_item) + if name is not None: + name_item = TextContentItem( + name=CodedConcept( + value='121013', + meaning='Device Observer Name', + scheme_designator='DCM', + ), + value=name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(name_item) + if manufacturer_name is not None: + manufacturer_name_item = TextContentItem( + name=CodedConcept( + value='121014', + meaning='Device Observer Manufacturer', + scheme_designator='DCM', + ), + value=manufacturer_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(manufacturer_name_item) + if model_name is not None: + model_name_item = TextContentItem( + name=CodedConcept( + value='121015', + meaning='Device Observer Model Name', + scheme_designator='DCM', + ), + value=model_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(model_name_item) + if serial_number is not None: + serial_number_item = TextContentItem( + name=CodedConcept( + value='121016', + meaning='Device Observer Serial Number', + scheme_designator='DCM', + ), + value=serial_number, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(serial_number_item) + if physical_location is not None: + physical_location_item = TextContentItem( + name=codes.DCM.DeviceObserverPhysicalLocationDuringObservation, + value=physical_location, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(physical_location_item) + if role_in_procedure is not None: + role_in_procedure_item = CodeContentItem( + name=codes.DCM.DeviceRoleInProcedure, + value=role_in_procedure, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(role_in_procedure_item) + + @property + def uid(self) -> UID: + """highdicom.UID: unique device identifier""" + return UID(self[0].value) + + @property + def name(self) -> str | None: + """Union[str, None]: name of device""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceObserverName + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Observer Name" content item ' + 'in "Device Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def manufacturer_name(self) -> str | None: + """Union[str, None]: name of device manufacturer""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceObserverManufacturer + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Observer Manufacturer" content ' + 'name in "Device Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def model_name(self) -> str | None: + """Union[str, None]: name of device model""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceObserverModelName + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Observer Model Name" content ' + 'item in "Device Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def serial_number(self) -> str | None: + """Union[str, None]: device serial number""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceObserverSerialNumber + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Observer Serial Number" content ' + 'item in "Device Observer Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def physical_location(self) -> str | None: + """Union[str, None]: location of device""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceObserverPhysicalLocationDuringObservation # noqa: E501 + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Observer Physical Location ' + 'During Observation" content item in "Device Observer ' + 'Identifying Attributes" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @classmethod + def from_sequence( + cls, + sequence: Sequence[Dataset], + is_root: bool = False + ) -> Self: + """Construct object from a sequence of datasets. + + Parameters + ---------- + sequence: Sequence[pydicom.dataset.Dataset] + Datasets representing SR Content Items of template + TID 1004 "Device Observer Identifying Attributes" + is_root: bool, optional + Whether the sequence is used to contain SR Content Items that are + intended to be added to an SR document at the root of the document + content tree + + Returns + ------- + highdicom.sr.templates.DeviceObserverIdentifyingAttributes + Content Sequence containing SR Content Items + + """ + attr_codes = [ + ('name', codes.DCM.DeviceObserverName), + ('uid', codes.DCM.DeviceObserverUID), + ('manufacturer_name', codes.DCM.DeviceObserverManufacturer), + ('model_name', codes.DCM.DeviceObserverModelName), + ('serial_number', codes.DCM.DeviceObserverSerialNumber), + ('physical_location', + codes.DCM.DeviceObserverPhysicalLocationDuringObservation), + ] + kwargs = {} + for dataset in sequence: + dataset_copy = deepcopy(dataset) + content_item = ContentItem._from_dataset_derived(dataset_copy) + for param, name in attr_codes: + if content_item.name == name: + kwargs[param] = content_item.value + return cls(**kwargs) + + +class ObserverContext(Template): + + """:dcm:`TID 1002 ` + Observer Context""" + + def __init__( + self, + observer_type: CodedConcept, + observer_identifying_attributes: ( + PersonObserverIdentifyingAttributes | + DeviceObserverIdentifyingAttributes + ) + ): + """ + + Parameters + ---------- + observer_type: highdicom.sr.CodedConcept + type of observer (see :dcm:`CID 270 ` + "Observer Type" for options) + observer_identifying_attributes: Union[highdicom.sr.PersonObserverIdentifyingAttributes, highdicom.sr.DeviceObserverIdentifyingAttributes] + observer identifying attributes + + """ # noqa: E501 + super().__init__() + observer_type_item = CodeContentItem( + name=CodedConcept( + value='121005', + meaning='Observer Type', + scheme_designator='DCM', + ), + value=observer_type, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(observer_type_item) + if observer_type == codes.cid270.Person: + if not isinstance(observer_identifying_attributes, + PersonObserverIdentifyingAttributes): + raise TypeError( + 'Observer identifying attributes must have ' + f'type {PersonObserverIdentifyingAttributes.__name__} ' + f'for observer type "{observer_type.meaning}".' + ) + elif observer_type == codes.cid270.Device: + if not isinstance(observer_identifying_attributes, + DeviceObserverIdentifyingAttributes): + raise TypeError( + 'Observer identifying attributes must have ' + f'type {DeviceObserverIdentifyingAttributes.__name__} ' + f'for observer type "{observer_type.meaning}".' + ) + else: + raise ValueError( + 'Argument "oberver_type" must be either "Person" or "Device".' + ) + self.extend(observer_identifying_attributes) + + @property + def observer_type(self) -> CodedConcept: + """highdicom.sr.CodedConcept: observer type""" + return self[0].value + + @property + def observer_identifying_attributes(self) -> ( + PersonObserverIdentifyingAttributes | + DeviceObserverIdentifyingAttributes + ): + """Union[highdicom.sr.PersonObserverIdentifyingAttributes, highdicom.sr.DeviceObserverIdentifyingAttributes]: + observer identifying attributes + """ # noqa: E501 + if self.observer_type == codes.DCM.Device: + return DeviceObserverIdentifyingAttributes.from_sequence(self) + elif self.observer_type == codes.DCM.Person: + return PersonObserverIdentifyingAttributes.from_sequence(self) + else: + raise ValueError( + f'Unexpected observer type "{self.observer_type.meaning}"' + ) + + +class SubjectContextFetus(Template): + + """:dcm:`TID 1008 ` + Subject Context Fetus""" + + def __init__( + self, + subject_id: str + ) -> None: + """ + + Parameters + ---------- + subject_id: str + identifier of the fetus for longitudinal tracking + + """ + super().__init__() + subject_id_item = TextContentItem( + name=CodedConcept( + value='121030', + meaning='Subject ID', + scheme_designator='DCM' + ), + value=subject_id, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(subject_id_item) + + @property + def subject_id(self) -> str: + """str: subject identifier""" + return self[0].value + + @classmethod + def from_sequence( + cls, + sequence: Sequence[Dataset], + is_root: bool = False + ) -> Self: + """Construct object from a sequence of datasets. + + Parameters + ---------- + sequence: Sequence[pydicom.dataset.Dataset] + Datasets representing SR Content Items of template + TID 1008 "Subject Context, Fetus" + is_root: bool, optional + Whether the sequence is used to contain SR Content Items that are + intended to be added to an SR document at the root of the document + content tree + + Returns + ------- + highdicom.sr.SubjectContextFetus + Content Sequence containing SR Content Items + + """ + attr_codes = [ + ('subject_id', codes.DCM.SubjectID), + ] + kwargs = {} + for dataset in sequence: + dataset_copy = deepcopy(dataset) + content_item = ContentItem._from_dataset_derived(dataset_copy) + for param, name in attr_codes: + if content_item.name == name: + kwargs[param] = content_item.value + return cls(**kwargs) + + +class SubjectContextSpecimen(Template): + + """:dcm:`TID 1009 ` + Subject Context Specimen""" + + def __init__( + self, + uid: str, + identifier: str | None = None, + container_identifier: str | None = None, + specimen_type: Code | CodedConcept | None = None + ): + """ + + Parameters + ---------- + uid: str + Unique identifier of the observed specimen + identifier: Union[str, None], optional + Identifier of the observed specimen (may have limited scope, + e.g., only relevant with respect to the corresponding container) + container_identifier: Union[str, None], optional + Identifier of the container holding the specimen (e.g., a glass + slide) + specimen_type: Union[pydicom.sr.coding.Code, highdicom.sr.CodedConcept, None], optional + Type of the specimen (see + :dcm:`CID 8103 ` + "Anatomic Pathology Specimen Types" for options) + + """ # noqa: E501 + super().__init__() + specimen_uid_item = UIDRefContentItem( + name=CodedConcept( + value='121039', + meaning='Specimen UID', + scheme_designator='DCM' + ), + value=uid, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(specimen_uid_item) + if identifier is not None: + specimen_identifier_item = TextContentItem( + name=CodedConcept( + value='121041', + meaning='Specimen Identifier', + scheme_designator='DCM' + ), + value=identifier, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(specimen_identifier_item) + if specimen_type is not None: + specimen_type_item = CodeContentItem( + name=CodedConcept( + value='371439000', + meaning='Specimen Type', + scheme_designator='SCT' + ), + value=specimen_type, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(specimen_type_item) + if container_identifier is not None: + container_identifier_item = TextContentItem( + name=CodedConcept( + value='111700', + meaning='Specimen Container Identifier', + scheme_designator='DCM' + ), + value=container_identifier, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(container_identifier_item) + + @property + def specimen_uid(self) -> str: + """str: unique specimen identifier""" + return self[0].value + + @property + def specimen_identifier(self) -> str | None: + """Union[str, None]: specimen identifier""" + matches = [ + item for item in self + if item.name == codes.DCM.SpecimenIdentifier + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Specimen Identifier" content ' + 'item in "Subject Context Specimen" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def container_identifier(self) -> str | None: + """Union[str, None]: specimen container identifier""" + matches = [ + item for item in self + if item.name == codes.DCM.SpecimenContainerIdentifier + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Specimen Container Identifier" content ' + 'item in "Subject Context Specimen" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def specimen_type(self) -> CodedConcept | None: + """Union[highdicom.sr.CodedConcept, None]: type of specimen""" + matches = [ + item for item in self + if item.name == codes.SCT.SpecimenType + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Specimen Type" content ' + 'item in "Subject Context Specimen" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @classmethod + def from_image( + cls, + image: Dataset, + ) -> Self: + """Deduce specimen information from an existing image. + + This is appropriate, for example, when copying the specimen information + from a source image into a derived SR or similar object. + + Parameters + ---------- + image: pydicom.Dataset + An image from which to infer specimen information. There is no + limitation on the type of image, however it must have the Specimen + module included. + + Raises + ------ + ValueError: + If the input image does not contain specimen information. + + """ + if not hasattr(image, 'ContainerIdentifier'): + raise ValueError("Image does not contain specimen information.") + + description = image.SpecimenDescriptionSequence[0] + + # Specimen type code sequence is optional + if hasattr(description, 'SpecimenTypeCodeSequence'): + specimen_type: CodedConcept | None = CodedConcept.from_dataset( + description.SpecimenTypeCodeSequence[0] + ) + else: + specimen_type = None + + return cls( + container_identifier=image.ContainerIdentifier, + identifier=description.SpecimenIdentifier, + uid=description.SpecimenUID, + specimen_type=specimen_type, + ) + + @classmethod + def from_sequence( + cls, + sequence: Sequence[Dataset], + is_root: bool = False + ) -> Self: + """Construct object from a sequence of datasets. + + Parameters + ---------- + sequence: Sequence[pydicom.dataset.Dataset] + Datasets representing SR Content Items of template + TID 1009 "Subject Context, Specimen" + is_root: bool, optional + Whether the sequence is used to contain SR Content Items that are + intended to be added to an SR document at the root of the document + content tree + + Returns + ------- + highdicom.sr.SubjectContextSpecimen + Content Sequence containing SR Content Items + + """ + attr_codes = [ + ('uid', codes.DCM.SpecimenUID), + ('identifier', codes.DCM.SpecimenIdentifier), + ('container_identifier', codes.DCM.SpecimenContainerIdentifier), + ('specimen_type', codes.SCT.SpecimenType), + ] + kwargs = {} + for dataset in sequence: + dataset_copy = deepcopy(dataset) + content_item = ContentItem._from_dataset_derived(dataset_copy) + for param, name in attr_codes: + if content_item.name == name: + kwargs[param] = content_item.value + return cls(**kwargs) + + +class SubjectContextDevice(Template): + + """:dcm:`TID 1010 ` + Subject Context Device""" + + def __init__( + self, + name: str, + uid: str | None = None, + manufacturer_name: str | None = None, + model_name: str | None = None, + serial_number: str | None = None, + physical_location: str | None = None + ): + """ + + Parameters + ---------- + name: str + name of the observed device + uid: Union[str, None], optional + unique identifier of the observed device + manufacturer_name: Union[str, None], optional + name of the observed device's manufacturer + model_name: Union[str, None], optional + name of the observed device's model + serial_number: Union[str, None], optional + serial number of the observed device + physical_location: str, optional + physical location of the observed device during the procedure + + """ + super().__init__() + device_name_item = TextContentItem( + name=codes.DCM.DeviceSubjectName, + value=name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(device_name_item) + if uid is not None: + device_uid_item = UIDRefContentItem( + name=codes.DCM.DeviceSubjectUID, + value=uid, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(device_uid_item) + if manufacturer_name is not None: + manufacturer_name_item = TextContentItem( + name=CodedConcept( + value='121194', + meaning='Device Subject Manufacturer', + scheme_designator='DCM', + ), + value=manufacturer_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(manufacturer_name_item) + if model_name is not None: + model_name_item = TextContentItem( + name=CodedConcept( + value='121195', + meaning='Device Subject Model Name', + scheme_designator='DCM', + ), + value=model_name, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(model_name_item) + if serial_number is not None: + serial_number_item = TextContentItem( + name=CodedConcept( + value='121196', + meaning='Device Subject Serial Number', + scheme_designator='DCM', + ), + value=serial_number, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(serial_number_item) + if physical_location is not None: + physical_location_item = TextContentItem( + name=codes.DCM.DeviceSubjectPhysicalLocationDuringObservation, + value=physical_location, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(physical_location_item) + + @property + def device_name(self) -> str: + """str: name of device""" + return self[0].value + + @property + def device_uid(self) -> str | None: + """Union[str, None]: unique device identifier""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceSubjectUID + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Subject UID" content ' + 'item in "Subject Context Device" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def device_manufacturer_name(self) -> str | None: + """Union[str, None]: name of device manufacturer""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceSubjectManufacturer + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Subject Manufacturer" content ' + 'item in "Subject Context Device" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def device_model_name(self) -> str | None: + """Union[str, None]: name of device model""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceSubjectModelName + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Subject Model Name" content ' + 'item in "Subject Context Device" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def device_serial_number(self) -> str | None: + """Union[str, None]: device serial number""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceSubjectSerialNumber + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Subject Serial Number" content ' + 'item in "Subject Context Device" template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @property + def device_physical_location(self) -> str | None: + """Union[str, None]: location of device""" + matches = [ + item for item in self + if item.name == codes.DCM.DeviceSubjectPhysicalLocationDuringObservation # noqa: E501 + ] + if len(matches) > 1: + logger.warning( + 'found more than one "Device Subject Physical Location ' + 'During Observation" content item in "Subject Context Device" ' + 'template' + ) + if len(matches) > 0: + return matches[0].value + return None + + @classmethod + def from_sequence( + cls, + sequence: Sequence[Dataset], + is_root: bool = False + ) -> Self: + """Construct object from a sequence of datasets. + + Parameters + ---------- + sequence: Sequence[pydicom.dataset.Dataset] + Datasets representing SR Content Items of template + TID 1010 "Subject Context, Device" + is_root: bool, optional + Whether the sequence is used to contain SR Content Items that are + intended to be added to an SR document at the root of the document + content tree + + Returns + ------- + highdicom.sr.SubjectContextDevice + Content Sequence containing SR Content Items + + """ + attr_codes = [ + ('name', codes.DCM.DeviceSubjectName), + ('uid', codes.DCM.DeviceSubjectUID), + ('manufacturer_name', codes.DCM.DeviceSubjectManufacturer), + ('model_name', codes.DCM.DeviceSubjectModelName), + ('serial_number', codes.DCM.DeviceSubjectSerialNumber), + ( + 'physical_location', + codes.DCM.DeviceSubjectPhysicalLocationDuringObservation + ), + ] + kwargs = {} + for dataset in sequence: + dataset_copy = deepcopy(dataset) + content_item = ContentItem._from_dataset_derived(dataset_copy) + for param, name in attr_codes: + if content_item.name == name: + kwargs[param] = content_item.value + return cls(**kwargs) + + +class SubjectContext(Template): + + """:dcm:`TID 1006 ` + Subject Context""" + + def __init__( + self, + subject_class: CodedConcept, + subject_class_specific_context: ( + SubjectContextFetus | + SubjectContextSpecimen | + SubjectContextDevice + ) + ): + """ + + Parameters + ---------- + subject_class: highdicom.sr.CodedConcept + type of subject if the subject of the report is not the patient + (see :dcm:`CID 271 ` + "Observation Subject Class" for options) + subject_class_specific_context: Union[highdicom.sr.SubjectContextFetus, highdicom.sr.SubjectContextSpecimen, highdicom.sr.SubjectContextDevice], optional + additional context information specific to `subject_class` + + """ # noqa: E501 + super().__init__() + subject_class_item = CodeContentItem( + name=CodedConcept( + value='121024', + meaning='Subject Class', + scheme_designator='DCM' + ), + value=subject_class, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(subject_class_item) + if isinstance(subject_class_specific_context, SubjectContextSpecimen): + if subject_class != codes.DCM.Specimen: + raise TypeError( + 'Subject class specific context doesn\'t match ' + 'subject class "Specimen".' + ) + elif isinstance(subject_class_specific_context, SubjectContextFetus): + if subject_class != codes.DCM.Fetus: + raise TypeError( + 'Subject class specific context doesn\'t match ' + 'subject class "Fetus".' + ) + elif isinstance(subject_class_specific_context, SubjectContextDevice): + if subject_class != codes.DCM.Device: + raise TypeError( + 'Subject class specific context doesn\'t match ' + 'subject class "Device".' + ) + else: + raise TypeError('Unexpected subject class specific context.') + self.extend(subject_class_specific_context) + + @classmethod + def from_image(cls, image: Dataset) -> Self | None: + """Get a subject context inferred from an existing image. + + Currently this is only supported for subjects that are specimens. + + Parameters + ---------- + image: pydicom.Dataset + Dataset of an existing DICOM image object + containing metadata on the imaging subject. Highdicom will attempt + to infer the subject context from this image. If successful, it + will be returned as a ``SubjectContext``, otherwise ``None``. + + Returns + ------- + Optional[highdicom.sr.SubjectContext]: + SubjectContext, if it can be inferred from the image. Otherwise, + ``None``. + + """ + try: + subject_context_specimen = SubjectContextSpecimen.from_image( + image + ) + except ValueError: + pass + else: + return cls( + subject_class=codes.DCM.Specimen, + subject_class_specific_context=subject_context_specimen, + ) + + return None + + @property + def subject_class(self) -> CodedConcept: + """highdicom.sr.CodedConcept: type of subject""" + return self[0].value + + @property + def subject_class_specific_context(self) -> ( + SubjectContextFetus | + SubjectContextSpecimen | + SubjectContextDevice + ): + """Union[highdicom.sr.SubjectContextFetus, highdicom.sr.SubjectContextSpecimen, highdicom.sr.SubjectContextDevice]: + subject class specific context + """ # noqa: E501 + if self.subject_class == codes.DCM.Specimen: + return SubjectContextSpecimen.from_sequence(sequence=self) + elif self.subject_class == codes.DCM.Fetus: + return SubjectContextFetus.from_sequence(sequence=self) + elif self.subject_class == codes.DCM.Device: + return SubjectContextDevice.from_sequence(sequence=self) + else: + raise ValueError('Unexpected subject class "{item.meaning}".') + + +class ObservationContext(Template): + + """:dcm:`TID 1001 ` + Observation Context""" + + def __init__( + self, + observer_person_context: ObserverContext | None = None, + observer_device_context: ObserverContext | None = None, + subject_context: SubjectContext | None = None + ): + """ + + Parameters + ---------- + observer_person_context: Union[highdicom.sr.ObserverContext, None], optional + description of the person that reported the observation + observer_device_context: Union[highdicom.sr.ObserverContext, None], optional + description of the device that was involved in reporting the + observation + subject_context: Union[highdicom.sr.SubjectContext, None], optional + description of the imaging subject in case it is not the patient + for which the report is generated (e.g., a pathology specimen in + a whole-slide microscopy image, a fetus in an ultrasound image, or + a pacemaker device in a chest X-ray image) + + """ # noqa: E501 + super().__init__() + if observer_person_context is not None: + if not isinstance(observer_person_context, ObserverContext): + raise TypeError( + 'Argument "observer_person_context" must ' + f'have type {ObserverContext.__name__}' + ) + self.extend(observer_person_context) + if observer_device_context is not None: + if not isinstance(observer_device_context, ObserverContext): + raise TypeError( + 'Argument "observer_device_context" must ' + f'have type {ObserverContext.__name__}' + ) + self.extend(observer_device_context) + if subject_context is not None: + if not isinstance(subject_context, SubjectContext): + raise TypeError( + f'Argument "subject_context" must have ' + f'type {SubjectContext.__name__}' + ) + self.extend(subject_context) + + +class LanguageOfValue(CodeContentItem): + """:dcm:`TID 1201 ` + Language of Value + """ + + def __init__( + self, + language: Union[Code, CodedConcept], + country_of_language: Optional[Union[Code, CodedConcept]] = None + ) -> None: + super().__init__( + name=codes.DCM.LanguageOfValue, + value=language, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content = ContentSequence() + if country_of_language is not None: + country_of_language_item = CodeContentItem( + name=codes.DCM.CountryOfLanguage, + value=country_of_language, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content.append(country_of_language_item) + if len(content) > 0: + self.ContentSequence = content diff --git a/src/highdicom/sr/templates.py b/src/highdicom/sr/templates/tid1500.py similarity index 77% rename from src/highdicom/sr/templates.py rename to src/highdicom/sr/templates/tid1500.py index 5141a775..756057dc 100644 --- a/src/highdicom/sr/templates.py +++ b/src/highdicom/sr/templates/tid1500.py @@ -1,7 +1,6 @@ """DICOM structured reporting templates.""" import collections import logging -from copy import deepcopy from typing import cast from collections.abc import Iterable, Sequence from typing_extensions import Self @@ -34,6 +33,19 @@ RelationshipTypeValues, ValueTypeValues, ) +from highdicom.sr.templates.common import ( + DeviceObserverIdentifyingAttributes, + LanguageOfContentItemAndDescendants, + ObservationContext, + ObserverContext, + PersonObserverIdentifyingAttributes, + SubjectContext, + SubjectContextDevice, + SubjectContextFetus, + SubjectContextSpecimen, + Template, + AlgorithmIdentification +) from highdicom.uid import UID from highdicom.sr.utils import ( find_content_items, @@ -46,7 +58,6 @@ ContentSequence, ImageContentItem, NumContentItem, - PnameContentItem, Scoord3DContentItem, TextContentItem, UIDRefContentItem, @@ -162,10 +173,10 @@ def _contains_planar_rois(group_item: ContainerContentItem) -> bool: n_image_region_items == 1 or n_referenced_segmentation_frame_items > 0 or n_region_in_space_items == 1 - ) and ( + ) and ( n_volume_surface_items == 0 and n_referenced_segment_items == 0 - ): + ): return True return False @@ -194,9 +205,9 @@ def _contains_volumetric_rois(group_item: ContainerContentItem) -> bool: n_referenced_segment_items > 0 or n_volume_surface_items > 0 or n_region_in_space_items > 0 - ) and ( + ) and ( n_referenced_segmentation_frame_items == 0 - ): + ): return True return False @@ -637,87 +648,6 @@ def _get_coded_modality(sop_class_uid: str) -> Code: ) from e -class Template(ContentSequence): - - """Abstract base class for a DICOM SR template.""" - - def __init__( - self, - items: Sequence[ContentItem] | None = None, - is_root: bool = False - ) -> None: - """ - - Parameters - ---------- - items: Sequence[ContentItem], optional - content items - is_root: bool - Whether this template exists at the root of the SR document - content tree. - - """ - super().__init__(items, is_root=is_root) - - -class AlgorithmIdentification(Template): - - """:dcm:`TID 4019 ` - Algorithm Identification""" - - def __init__( - self, - name: str, - version: str, - parameters: Sequence[str] | None = None - ) -> None: - """ - - Parameters - ---------- - name: str - name of the algorithm - version: str - version of the algorithm - parameters: Union[Sequence[str], None], optional - parameters of the algorithm - - """ - super().__init__() - name_item = TextContentItem( - name=CodedConcept( - value='111001', - meaning='Algorithm Name', - scheme_designator='DCM' - ), - value=name, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD - ) - self.append(name_item) - version_item = TextContentItem( - name=CodedConcept( - value='111003', - meaning='Algorithm Version', - scheme_designator='DCM' - ), - value=version, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD - ) - self.append(version_item) - if parameters is not None: - for param in parameters: - parameter_item = TextContentItem( - name=CodedConcept( - value='111002', - meaning='Algorithm Parameters', - scheme_designator='DCM' - ), - value=param, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD - ) - self.append(parameter_item) - - class TrackingIdentifier(Template): """:dcm:`TID 4108 ` Tracking Identifier""" @@ -856,1460 +786,254 @@ def __init__( value='126071', meaning='Protocol Time Point Identifier', scheme_designator='DCM' - ), - value=protocol_time_point_identifier, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(protocol_time_point_identifier_item) - if temporal_offset_from_event is not None: - if not isinstance(temporal_offset_from_event, - LongitudinalTemporalOffsetFromEvent): - raise TypeError( - 'Argument "temporal_offset_from_event" must have type ' - 'LongitudinalTemporalOffsetFromEvent.' - ) - self.append(temporal_offset_from_event) - - -class MeasurementStatisticalProperties(Template): - - """:dcm:`TID 311 ` - Measurement Statistical Properties""" - - def __init__( - self, - values: Sequence[NumContentItem], - description: str | None = None, - authority: str | None = None - ): - """ - - Parameters - ---------- - values: Sequence[highdicom.sr.NumContentItem] - reference values of the population of measurements, e.g., its - mean or standard deviation (see - :dcm:`CID 226 ` - "Population Statistical Descriptors" and - :dcm:`CID 227 ` - "Sample Statistical Descriptors" for options) - description: Union[str, None], optional - description of the reference population of measurements - authority: Union[str, None], optional - authority for a description of the reference population of - measurements - - """ - super().__init__() - if not isinstance(values, (list, tuple)): - raise TypeError('Argument "values" must be a list.') - for concept in values: - if not isinstance(concept, NumContentItem): - raise ValueError( - 'Items of argument "values" must have type ' - 'NumContentItem.' - ) - self.extend(values) - if description is not None: - description_item = TextContentItem( - name=CodedConcept( - value='121405', - meaning='Population Description', - scheme_designator='DCM' - ), - value=description, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(description_item) - if authority is not None: - authority_item = TextContentItem( - name=CodedConcept( - value='121406', - meaning='Reference Authority', - scheme_designator='DCM' - ), - value=authority, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(authority_item) - - -class NormalRangeProperties(Template): - - """:dcm:`TID 312 ` - Normal Range Properties""" - - def __init__( - self, - values: Sequence[NumContentItem], - description: str | None = None, - authority: str | None = None - ): - """ - - Parameters - ---------- - values: Sequence[highdicom.sr.NumContentItem] - reference values of the normal range, e.g., its upper and lower - bound (see :dcm:`CID 223 ` - "Normal Range Values" for options) - description: Union[str, None], optional - description of the normal range - authority: Union[str, None], optional - authority for the description of the normal range - - """ # noqa: E501 - super().__init__() - if not isinstance(values, (list, tuple)): - raise TypeError('Argument "values" must be a list.') - for concept in values: - if not isinstance(concept, NumContentItem): - raise ValueError( - 'Items of argument "values" must have type ' - 'NumContentItem.' - ) - self.extend(values) - if description is not None: - description_item = TextContentItem( - name=codes.DCM.NormalRangeDescription, - value=description, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(description_item) - if authority is not None: - authority_item = TextContentItem( - name=codes.DCM.NormalRangeAuthority, - value=authority, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(authority_item) - - -class MeasurementProperties(Template): - - """:dcm:`TID 310 ` - Measurement Properties""" - - def __init__( - self, - normality: CodedConcept | Code | None = None, - level_of_significance: CodedConcept | Code | None = None, - selection_status: CodedConcept | Code | None = None, - measurement_statistical_properties: None | ( - MeasurementStatisticalProperties - ) = None, - normal_range_properties: NormalRangeProperties | None = None, - upper_measurement_uncertainty: int | float | None = None, - lower_measurement_uncertainty: int | float | None = None - ): - """ - - Parameters - ---------- - normality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional - the extend to which the measurement is considered normal or abnormal - (see :dcm:`CID 222 ` "Normality Codes" for - options) - level_of_significance: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional - the extend to which the measurement is considered normal or abnormal - (see :dcm:`CID 220 ` "Level of - Significance" for options) - selection_status: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional - how the measurement value was selected or computed from a set of - available values (see :dcm:`CID 224 ` - "Selection Method" for options) - measurement_statistical_properties: Union[highdicom.sr.MeasurementStatisticalProperties, None], optional - statistical properties of a reference population for a measurement - and/or the position of a measurement in such a reference population - normal_range_properties: Union[highdicom.sr.NormalRangeProperties, None], optional - statistical properties of a reference population for a measurement - and/or the position of a measurement in such a reference population - upper_measurement_uncertainty: Union[int, float, None], optional - upper range of measurement uncertainty - lower_measurement_uncertainty: Union[int, float, None], optional - lower range of measurement uncertainty - - """ # noqa: E501 - super().__init__() - if normality is not None: - normality_item = CodeContentItem( - name=CodedConcept( - value='121402', - meaning='Normality', - scheme_designator='DCM' - ), - value=normality, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(normality_item) - if measurement_statistical_properties is not None: - if not isinstance(measurement_statistical_properties, - MeasurementStatisticalProperties): - raise TypeError( - 'Argument "measurement_statistical_properties" must have ' - 'type MeasurementStatisticalProperties.' - ) - self.extend(measurement_statistical_properties) - if normal_range_properties is not None: - if not isinstance(normal_range_properties, - NormalRangeProperties): - raise TypeError( - 'Argument "normal_range_properties" must have ' - 'type NormalRangeProperties.' - ) - self.extend(normal_range_properties) - if level_of_significance is not None: - level_of_significance_item = CodeContentItem( - name=CodedConcept( - value='121403', - meaning='Level of Significance', - scheme_designator='DCM' - ), - value=level_of_significance, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(level_of_significance_item) - if selection_status is not None: - selection_status_item = CodeContentItem( - name=CodedConcept( - value='121404', - meaning='Selection Status', - scheme_designator='DCM' - ), - value=selection_status, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(selection_status_item) - if upper_measurement_uncertainty is not None: - upper_measurement_uncertainty_item = NumContentItem( - name=CodedConcept( - value='371886008', - meaning='+, range of upper measurement uncertainty', - scheme_designator='SCT' - ), - value=upper_measurement_uncertainty, - unit=codes.UCUM.NoUnits, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(upper_measurement_uncertainty_item) - if lower_measurement_uncertainty is not None: - lower_measurement_uncertainty_item = NumContentItem( - name=CodedConcept( - value='371885007', - meaning='-, range of lower measurement uncertainty', - scheme_designator='SCT' - ), - value=lower_measurement_uncertainty, - unit=codes.UCUM.NoUnits, - relationship_type=RelationshipTypeValues.HAS_PROPERTIES - ) - self.append(lower_measurement_uncertainty_item) - - -class PersonObserverIdentifyingAttributes(Template): - - """:dcm:`TID 1003 ` - Person Observer Identifying Attributes""" - - def __init__( - self, - name: str, - login_name: str | None = None, - organization_name: str | None = None, - role_in_organization: CodedConcept | Code | None = None, - role_in_procedure: CodedConcept | Code | None = None - ): - """ - - Parameters - ---------- - name: str - name of the person - login_name: Union[str, None], optional - login name of the person - organization_name: Union[str, None], optional - name of the person's organization - role_in_organization: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional - role of the person within the organization - role_in_procedure: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional - role of the person in the reported procedure - - """ # noqa: E501 - super().__init__() - name_item = PnameContentItem( - name=CodedConcept( - value='121008', - meaning='Person Observer Name', - scheme_designator='DCM', - ), - value=name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(name_item) - if login_name is not None: - login_name_item = TextContentItem( - name=CodedConcept( - value='128774', - meaning='Person Observer\'s Login Name', - scheme_designator='DCM', - ), - value=login_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(login_name_item) - if organization_name is not None: - organization_name_item = TextContentItem( - name=CodedConcept( - value='121009', - meaning='Person Observer\'s Organization Name', - scheme_designator='DCM', - ), - value=organization_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(organization_name_item) - if role_in_organization is not None: - role_in_organization_item = CodeContentItem( - name=CodedConcept( - value='121010', - meaning='Person Observer\'s Role in the Organization', - scheme_designator='DCM', - ), - value=role_in_organization, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(role_in_organization_item) - if role_in_procedure is not None: - role_in_procedure_item = CodeContentItem( - name=CodedConcept( - value='121011', - meaning='Person Observer\'s Role in this Procedure', - scheme_designator='DCM', - ), - value=role_in_procedure, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(role_in_procedure_item) - - @property - def name(self) -> str: - """str: name of the person""" - return self[0].value - - @property - def login_name(self) -> str | None: - """Union[str, None]: login name of the person""" - matches = [ - item for item in self - if item.name == codes.DCM.PersonObserverLoginName - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Login Name" content item ' - 'in "Person Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def organization_name(self) -> str | None: - """Union[str, None]: name of the person's organization""" - matches = [ - item for item in self - if item.name == codes.DCM.PersonObserverOrganizationName - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Organization Name" content item ' - 'in "Person Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def role_in_organization(self) -> str | None: - """Union[str, None]: role of the person in the organization""" - matches = [ - item for item in self - if item.name == codes.DCM.PersonObserverRoleInTheOrganization - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Role in Organization" content item ' - 'in "Person Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def role_in_procedure(self) -> str | None: - """Union[str, None]: role of the person in the procedure""" - matches = [ - item for item in self - if item.name == codes.DCM.PersonObserverRoleInThisProcedure - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Role in Procedure" content item ' - 'in "Person Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @classmethod - def from_sequence( - cls, - sequence: Sequence[Dataset], - is_root: bool = False - ) -> Self: - """Construct object from a sequence of datasets. - - Parameters - ---------- - sequence: Sequence[pydicom.dataset.Dataset] - Datasets representing SR Content Items of template - TID 1003 "Person Observer Identifying Attributes" - is_root: bool, optional - Whether the sequence is used to contain SR Content Items that are - intended to be added to an SR document at the root of the document - content tree - - Returns - ------- - highdicom.sr.PersonObserverIdentifyingAttributes - Content Sequence containing SR Content Items - - """ - attr_codes = [ - ('name', codes.DCM.PersonObserverName), - ('login_name', codes.DCM.PersonObserverLoginName), - ('organization_name', - codes.DCM.PersonObserverOrganizationName), - ('role_in_organization', - codes.DCM.PersonObserverRoleInTheOrganization), - ('role_in_procedure', - codes.DCM.PersonObserverRoleInThisProcedure), - ] - kwargs = {} - for dataset in sequence: - dataset_copy = deepcopy(dataset) - content_item = ContentItem._from_dataset_derived(dataset_copy) - for param, name in attr_codes: - if content_item.name == name: - kwargs[param] = content_item.value - return cls(**kwargs) - - -class DeviceObserverIdentifyingAttributes(Template): - - """:dcm:`TID 1004 ` - Device Observer Identifying Attributes - """ - - def __init__( - self, - uid: str, - name: str | None = None, - manufacturer_name: str | None = None, - model_name: str | None = None, - serial_number: str | None = None, - physical_location: str | None = None, - role_in_procedure: Code | CodedConcept | None = None - ): - """ - - Parameters - ---------- - uid: str - device UID - name: Union[str, None], optional - name of device - manufacturer_name: Union[str, None], optional - name of device's manufacturer - model_name: Union[str, None], optional - name of the device's model - serial_number: Union[str, None], optional - serial number of the device - physical_location: Union[str, None], optional - physical location of the device during the procedure - role_in_procedure: Union[pydicom.sr.coding.Code, highdicom.sr.CodedConcept, None], optional - role of the device in the reported procedure - - """ # noqa: E501 - super().__init__() - device_observer_item = UIDRefContentItem( - name=CodedConcept( - value='121012', - meaning='Device Observer UID', - scheme_designator='DCM', - ), - value=uid, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(device_observer_item) - if name is not None: - name_item = TextContentItem( - name=CodedConcept( - value='121013', - meaning='Device Observer Name', - scheme_designator='DCM', - ), - value=name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(name_item) - if manufacturer_name is not None: - manufacturer_name_item = TextContentItem( - name=CodedConcept( - value='121014', - meaning='Device Observer Manufacturer', - scheme_designator='DCM', - ), - value=manufacturer_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(manufacturer_name_item) - if model_name is not None: - model_name_item = TextContentItem( - name=CodedConcept( - value='121015', - meaning='Device Observer Model Name', - scheme_designator='DCM', - ), - value=model_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(model_name_item) - if serial_number is not None: - serial_number_item = TextContentItem( - name=CodedConcept( - value='121016', - meaning='Device Observer Serial Number', - scheme_designator='DCM', - ), - value=serial_number, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(serial_number_item) - if physical_location is not None: - physical_location_item = TextContentItem( - name=codes.DCM.DeviceObserverPhysicalLocationDuringObservation, - value=physical_location, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(physical_location_item) - if role_in_procedure is not None: - role_in_procedure_item = CodeContentItem( - name=codes.DCM.DeviceRoleInProcedure, - value=role_in_procedure, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(role_in_procedure_item) - - @property - def uid(self) -> UID: - """highdicom.UID: unique device identifier""" - return UID(self[0].value) - - @property - def name(self) -> str | None: - """Union[str, None]: name of device""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceObserverName - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Observer Name" content item ' - 'in "Device Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def manufacturer_name(self) -> str | None: - """Union[str, None]: name of device manufacturer""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceObserverManufacturer - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Observer Manufacturer" content ' - 'name in "Device Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def model_name(self) -> str | None: - """Union[str, None]: name of device model""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceObserverModelName - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Observer Model Name" content ' - 'item in "Device Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def serial_number(self) -> str | None: - """Union[str, None]: device serial number""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceObserverSerialNumber - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Observer Serial Number" content ' - 'item in "Device Observer Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def physical_location(self) -> str | None: - """Union[str, None]: location of device""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceObserverPhysicalLocationDuringObservation # noqa: E501 - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Observer Physical Location ' - 'During Observation" content item in "Device Observer ' - 'Identifying Attributes" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @classmethod - def from_sequence( - cls, - sequence: Sequence[Dataset], - is_root: bool = False - ) -> Self: - """Construct object from a sequence of datasets. - - Parameters - ---------- - sequence: Sequence[pydicom.dataset.Dataset] - Datasets representing SR Content Items of template - TID 1004 "Device Observer Identifying Attributes" - is_root: bool, optional - Whether the sequence is used to contain SR Content Items that are - intended to be added to an SR document at the root of the document - content tree - - Returns - ------- - highdicom.sr.templates.DeviceObserverIdentifyingAttributes - Content Sequence containing SR Content Items - - """ - attr_codes = [ - ('name', codes.DCM.DeviceObserverName), - ('uid', codes.DCM.DeviceObserverUID), - ('manufacturer_name', codes.DCM.DeviceObserverManufacturer), - ('model_name', codes.DCM.DeviceObserverModelName), - ('serial_number', codes.DCM.DeviceObserverSerialNumber), - ('physical_location', - codes.DCM.DeviceObserverPhysicalLocationDuringObservation), - ] - kwargs = {} - for dataset in sequence: - dataset_copy = deepcopy(dataset) - content_item = ContentItem._from_dataset_derived(dataset_copy) - for param, name in attr_codes: - if content_item.name == name: - kwargs[param] = content_item.value - return cls(**kwargs) - - -class ObserverContext(Template): - - """:dcm:`TID 1002 ` - Observer Context""" - - def __init__( - self, - observer_type: CodedConcept, - observer_identifying_attributes: ( - PersonObserverIdentifyingAttributes | - DeviceObserverIdentifyingAttributes - ) - ): - """ - - Parameters - ---------- - observer_type: highdicom.sr.CodedConcept - type of observer (see :dcm:`CID 270 ` - "Observer Type" for options) - observer_identifying_attributes: Union[highdicom.sr.PersonObserverIdentifyingAttributes, highdicom.sr.DeviceObserverIdentifyingAttributes] - observer identifying attributes - - """ # noqa: E501 - super().__init__() - observer_type_item = CodeContentItem( - name=CodedConcept( - value='121005', - meaning='Observer Type', - scheme_designator='DCM', - ), - value=observer_type, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(observer_type_item) - if observer_type == codes.cid270.Person: - if not isinstance(observer_identifying_attributes, - PersonObserverIdentifyingAttributes): - raise TypeError( - 'Observer identifying attributes must have ' - f'type {PersonObserverIdentifyingAttributes.__name__} ' - f'for observer type "{observer_type.meaning}".' - ) - elif observer_type == codes.cid270.Device: - if not isinstance(observer_identifying_attributes, - DeviceObserverIdentifyingAttributes): - raise TypeError( - 'Observer identifying attributes must have ' - f'type {DeviceObserverIdentifyingAttributes.__name__} ' - f'for observer type "{observer_type.meaning}".' - ) - else: - raise ValueError( - 'Argument "oberver_type" must be either "Person" or "Device".' - ) - self.extend(observer_identifying_attributes) - - @property - def observer_type(self) -> CodedConcept: - """highdicom.sr.CodedConcept: observer type""" - return self[0].value - - @property - def observer_identifying_attributes(self) -> ( - PersonObserverIdentifyingAttributes | - DeviceObserverIdentifyingAttributes - ): - """Union[highdicom.sr.PersonObserverIdentifyingAttributes, highdicom.sr.DeviceObserverIdentifyingAttributes]: - observer identifying attributes - """ # noqa: E501 - if self.observer_type == codes.DCM.Device: - return DeviceObserverIdentifyingAttributes.from_sequence(self) - elif self.observer_type == codes.DCM.Person: - return PersonObserverIdentifyingAttributes.from_sequence(self) - else: - raise ValueError( - f'Unexpected observer type "{self.observer_type.meaning}"' - ) - - -class SubjectContextFetus(Template): - - """:dcm:`TID 1008 ` - Subject Context Fetus""" - - def __init__(self, subject_id: str): - """ - - Parameters - ---------- - subject_id: str - identifier of the fetus for longitudinal tracking - - """ - super().__init__() - subject_id_item = TextContentItem( - name=CodedConcept( - value='121030', - meaning='Subject ID', - scheme_designator='DCM' - ), - value=subject_id, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(subject_id_item) - - @property - def subject_id(self) -> str: - """str: subject identifier""" - return self[0].value - - @classmethod - def from_sequence( - cls, - sequence: Sequence[Dataset], - is_root: bool = False - ) -> Self: - """Construct object from a sequence of datasets. - - Parameters - ---------- - sequence: Sequence[pydicom.dataset.Dataset] - Datasets representing SR Content Items of template - TID 1008 "Subject Context, Fetus" - is_root: bool, optional - Whether the sequence is used to contain SR Content Items that are - intended to be added to an SR document at the root of the document - content tree - - Returns - ------- - highdicom.sr.SubjectContextFetus - Content Sequence containing SR Content Items - - """ - attr_codes = [ - ('subject_id', codes.DCM.SubjectID), - ] - kwargs = {} - for dataset in sequence: - dataset_copy = deepcopy(dataset) - content_item = ContentItem._from_dataset_derived(dataset_copy) - for param, name in attr_codes: - if content_item.name == name: - kwargs[param] = content_item.value - return cls(**kwargs) - - -class SubjectContextSpecimen(Template): - - """:dcm:`TID 1009 ` - Subject Context Specimen""" - - def __init__( - self, - uid: str, - identifier: str | None = None, - container_identifier: str | None = None, - specimen_type: Code | CodedConcept | None = None - ): - """ - - Parameters - ---------- - uid: str - Unique identifier of the observed specimen - identifier: Union[str, None], optional - Identifier of the observed specimen (may have limited scope, - e.g., only relevant with respect to the corresponding container) - container_identifier: Union[str, None], optional - Identifier of the container holding the specimen (e.g., a glass - slide) - specimen_type: Union[pydicom.sr.coding.Code, highdicom.sr.CodedConcept, None], optional - Type of the specimen (see - :dcm:`CID 8103 ` - "Anatomic Pathology Specimen Types" for options) - - """ # noqa: E501 - super().__init__() - specimen_uid_item = UIDRefContentItem( - name=CodedConcept( - value='121039', - meaning='Specimen UID', - scheme_designator='DCM' - ), - value=uid, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(specimen_uid_item) - if identifier is not None: - specimen_identifier_item = TextContentItem( - name=CodedConcept( - value='121041', - meaning='Specimen Identifier', - scheme_designator='DCM' - ), - value=identifier, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(specimen_identifier_item) - if specimen_type is not None: - specimen_type_item = CodeContentItem( - name=CodedConcept( - value='371439000', - meaning='Specimen Type', - scheme_designator='SCT' - ), - value=specimen_type, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(specimen_type_item) - if container_identifier is not None: - container_identifier_item = TextContentItem( - name=CodedConcept( - value='111700', - meaning='Specimen Container Identifier', - scheme_designator='DCM' - ), - value=container_identifier, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(container_identifier_item) - - @property - def specimen_uid(self) -> str: - """str: unique specimen identifier""" - return self[0].value - - @property - def specimen_identifier(self) -> str | None: - """Union[str, None]: specimen identifier""" - matches = [ - item for item in self - if item.name == codes.DCM.SpecimenIdentifier - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Specimen Identifier" content ' - 'item in "Subject Context Specimen" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def container_identifier(self) -> str | None: - """Union[str, None]: specimen container identifier""" - matches = [ - item for item in self - if item.name == codes.DCM.SpecimenContainerIdentifier - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Specimen Container Identifier" content ' - 'item in "Subject Context Specimen" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def specimen_type(self) -> CodedConcept | None: - """Union[highdicom.sr.CodedConcept, None]: type of specimen""" - matches = [ - item for item in self - if item.name == codes.SCT.SpecimenType - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Specimen Type" content ' - 'item in "Subject Context Specimen" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @classmethod - def from_image( - cls, - image: Dataset, - ) -> Self: - """Deduce specimen information from an existing image. - - This is appropriate, for example, when copying the specimen information - from a source image into a derived SR or similar object. - - Parameters - ---------- - image: pydicom.Dataset - An image from which to infer specimen information. There is no - limitation on the type of image, however it must have the Specimen - module included. - - Raises - ------ - ValueError: - If the input image does not contain specimen information. - - """ - if not hasattr(image, 'ContainerIdentifier'): - raise ValueError("Image does not contain specimen information.") - - description = image.SpecimenDescriptionSequence[0] - - # Specimen type code sequence is optional - if hasattr(description, 'SpecimenTypeCodeSequence'): - specimen_type: CodedConcept | None = CodedConcept.from_dataset( - description.SpecimenTypeCodeSequence[0] - ) - else: - specimen_type = None - - return cls( - container_identifier=image.ContainerIdentifier, - identifier=description.SpecimenIdentifier, - uid=description.SpecimenUID, - specimen_type=specimen_type, - ) - - @classmethod - def from_sequence( - cls, - sequence: Sequence[Dataset], - is_root: bool = False - ) -> Self: - """Construct object from a sequence of datasets. - - Parameters - ---------- - sequence: Sequence[pydicom.dataset.Dataset] - Datasets representing SR Content Items of template - TID 1009 "Subject Context, Specimen" - is_root: bool, optional - Whether the sequence is used to contain SR Content Items that are - intended to be added to an SR document at the root of the document - content tree - - Returns - ------- - highdicom.sr.SubjectContextSpecimen - Content Sequence containing SR Content Items - - """ - attr_codes = [ - ('uid', codes.DCM.SpecimenUID), - ('identifier', codes.DCM.SpecimenIdentifier), - ('container_identifier', codes.DCM.SpecimenContainerIdentifier), - ('specimen_type', codes.SCT.SpecimenType), - ] - kwargs = {} - for dataset in sequence: - dataset_copy = deepcopy(dataset) - content_item = ContentItem._from_dataset_derived(dataset_copy) - for param, name in attr_codes: - if content_item.name == name: - kwargs[param] = content_item.value - return cls(**kwargs) - - -class SubjectContextDevice(Template): - - """:dcm:`TID 1010 ` - Subject Context Device""" - - def __init__( - self, - name: str, - uid: str | None = None, - manufacturer_name: str | None = None, - model_name: str | None = None, - serial_number: str | None = None, - physical_location: str | None = None - ): - """ - - Parameters - ---------- - name: str - name of the observed device - uid: Union[str, None], optional - unique identifier of the observed device - manufacturer_name: Union[str, None], optional - name of the observed device's manufacturer - model_name: Union[str, None], optional - name of the observed device's model - serial_number: Union[str, None], optional - serial number of the observed device - physical_location: str, optional - physical location of the observed device during the procedure - - """ - super().__init__() - device_name_item = TextContentItem( - name=codes.DCM.DeviceSubjectName, - value=name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(device_name_item) - if uid is not None: - device_uid_item = UIDRefContentItem( - name=codes.DCM.DeviceSubjectUID, - value=uid, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(device_uid_item) - if manufacturer_name is not None: - manufacturer_name_item = TextContentItem( - name=CodedConcept( - value='121194', - meaning='Device Subject Manufacturer', - scheme_designator='DCM', - ), - value=manufacturer_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(manufacturer_name_item) - if model_name is not None: - model_name_item = TextContentItem( - name=CodedConcept( - value='121195', - meaning='Device Subject Model Name', - scheme_designator='DCM', - ), - value=model_name, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(model_name_item) - if serial_number is not None: - serial_number_item = TextContentItem( - name=CodedConcept( - value='121196', - meaning='Device Subject Serial Number', - scheme_designator='DCM', - ), - value=serial_number, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(serial_number_item) - if physical_location is not None: - physical_location_item = TextContentItem( - name=codes.DCM.DeviceSubjectPhysicalLocationDuringObservation, - value=physical_location, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(physical_location_item) - - @property - def device_name(self) -> str: - """str: name of device""" - return self[0].value - - @property - def device_uid(self) -> str | None: - """Union[str, None]: unique device identifier""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceSubjectUID - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Subject UID" content ' - 'item in "Subject Context Device" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def device_manufacturer_name(self) -> str | None: - """Union[str, None]: name of device manufacturer""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceSubjectManufacturer - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Subject Manufacturer" content ' - 'item in "Subject Context Device" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def device_model_name(self) -> str | None: - """Union[str, None]: name of device model""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceSubjectModelName - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Subject Model Name" content ' - 'item in "Subject Context Device" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def device_serial_number(self) -> str | None: - """Union[str, None]: device serial number""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceSubjectSerialNumber - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Subject Serial Number" content ' - 'item in "Subject Context Device" template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @property - def device_physical_location(self) -> str | None: - """Union[str, None]: location of device""" - matches = [ - item for item in self - if item.name == codes.DCM.DeviceSubjectPhysicalLocationDuringObservation # noqa: E501 - ] - if len(matches) > 1: - logger.warning( - 'found more than one "Device Subject Physical Location ' - 'During Observation" content item in "Subject Context Device" ' - 'template' - ) - if len(matches) > 0: - return matches[0].value - return None - - @classmethod - def from_sequence( - cls, - sequence: Sequence[Dataset], - is_root: bool = False - ) -> Self: - """Construct object from a sequence of datasets. - - Parameters - ---------- - sequence: Sequence[pydicom.dataset.Dataset] - Datasets representing SR Content Items of template - TID 1010 "Subject Context, Device" - is_root: bool, optional - Whether the sequence is used to contain SR Content Items that are - intended to be added to an SR document at the root of the document - content tree - - Returns - ------- - highdicom.sr.SubjectContextDevice - Content Sequence containing SR Content Items - - """ - attr_codes = [ - ('name', codes.DCM.DeviceSubjectName), - ('uid', codes.DCM.DeviceSubjectUID), - ('manufacturer_name', codes.DCM.DeviceSubjectManufacturer), - ('model_name', codes.DCM.DeviceSubjectModelName), - ('serial_number', codes.DCM.DeviceSubjectSerialNumber), - ( - 'physical_location', - codes.DCM.DeviceSubjectPhysicalLocationDuringObservation - ), - ] - kwargs = {} - for dataset in sequence: - dataset_copy = deepcopy(dataset) - content_item = ContentItem._from_dataset_derived(dataset_copy) - for param, name in attr_codes: - if content_item.name == name: - kwargs[param] = content_item.value - return cls(**kwargs) + ), + value=protocol_time_point_identifier, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + self.append(protocol_time_point_identifier_item) + if temporal_offset_from_event is not None: + if not isinstance(temporal_offset_from_event, + LongitudinalTemporalOffsetFromEvent): + raise TypeError( + 'Argument "temporal_offset_from_event" must have type ' + 'LongitudinalTemporalOffsetFromEvent.' + ) + self.append(temporal_offset_from_event) -class SubjectContext(Template): +class MeasurementStatisticalProperties(Template): - """:dcm:`TID 1006 ` - Subject Context""" + """:dcm:`TID 311 ` + Measurement Statistical Properties""" def __init__( self, - subject_class: CodedConcept, - subject_class_specific_context: ( - SubjectContextFetus | - SubjectContextSpecimen | - SubjectContextDevice - ) + values: Sequence[NumContentItem], + description: str | None = None, + authority: str | None = None ): """ Parameters ---------- - subject_class: highdicom.sr.CodedConcept - type of subject if the subject of the report is not the patient - (see :dcm:`CID 271 ` - "Observation Subject Class" for options) - subject_class_specific_context: Union[highdicom.sr.SubjectContextFetus, highdicom.sr.SubjectContextSpecimen, highdicom.sr.SubjectContextDevice], optional - additional context information specific to `subject_class` + values: Sequence[highdicom.sr.NumContentItem] + reference values of the population of measurements, e.g., its + mean or standard deviation (see + :dcm:`CID 226 ` + "Population Statistical Descriptors" and + :dcm:`CID 227 ` + "Sample Statistical Descriptors" for options) + description: Union[str, None], optional + description of the reference population of measurements + authority: Union[str, None], optional + authority for a description of the reference population of + measurements - """ # noqa: E501 + """ super().__init__() - subject_class_item = CodeContentItem( - name=CodedConcept( - value='121024', - meaning='Subject Class', - scheme_designator='DCM' - ), - value=subject_class, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT - ) - self.append(subject_class_item) - if isinstance(subject_class_specific_context, SubjectContextSpecimen): - if subject_class != codes.DCM.Specimen: - raise TypeError( - 'Subject class specific context doesn\'t match ' - 'subject class "Specimen".' - ) - elif isinstance(subject_class_specific_context, SubjectContextFetus): - if subject_class != codes.DCM.Fetus: - raise TypeError( - 'Subject class specific context doesn\'t match ' - 'subject class "Fetus".' - ) - elif isinstance(subject_class_specific_context, SubjectContextDevice): - if subject_class != codes.DCM.Device: - raise TypeError( - 'Subject class specific context doesn\'t match ' - 'subject class "Device".' + if not isinstance(values, (list, tuple)): + raise TypeError('Argument "values" must be a list.') + for concept in values: + if not isinstance(concept, NumContentItem): + raise ValueError( + 'Items of argument "values" must have type ' + 'NumContentItem.' ) - else: - raise TypeError('Unexpected subject class specific context.') - self.extend(subject_class_specific_context) - - @classmethod - def from_image(cls, image: Dataset) -> Self | None: - """Get a subject context inferred from an existing image. - - Currently this is only supported for subjects that are specimens. - - Parameters - ---------- - image: pydicom.Dataset - Dataset of an existing DICOM image object - containing metadata on the imaging subject. Highdicom will attempt - to infer the subject context from this image. If successful, it - will be returned as a ``SubjectContext``, otherwise ``None``. - - Returns - ------- - Optional[highdicom.sr.SubjectContext]: - SubjectContext, if it can be inferred from the image. Otherwise, - ``None``. - - """ - try: - subject_context_specimen = SubjectContextSpecimen.from_image( - image + self.extend(values) + if description is not None: + description_item = TextContentItem( + name=CodedConcept( + value='121405', + meaning='Population Description', + scheme_designator='DCM' + ), + value=description, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES ) - except ValueError: - pass - else: - return cls( - subject_class=codes.DCM.Specimen, - subject_class_specific_context=subject_context_specimen, + self.append(description_item) + if authority is not None: + authority_item = TextContentItem( + name=CodedConcept( + value='121406', + meaning='Reference Authority', + scheme_designator='DCM' + ), + value=authority, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES ) - - return None - - @property - def subject_class(self) -> CodedConcept: - """highdicom.sr.CodedConcept: type of subject""" - return self[0].value - - @property - def subject_class_specific_context(self) -> ( - SubjectContextFetus | - SubjectContextSpecimen | - SubjectContextDevice - ): - """Union[highdicom.sr.SubjectContextFetus, highdicom.sr.SubjectContextSpecimen, highdicom.sr.SubjectContextDevice]: - subject class specific context - """ # noqa: E501 - if self.subject_class == codes.DCM.Specimen: - return SubjectContextSpecimen.from_sequence(sequence=self) - elif self.subject_class == codes.DCM.Fetus: - return SubjectContextFetus.from_sequence(sequence=self) - elif self.subject_class == codes.DCM.Device: - return SubjectContextDevice.from_sequence(sequence=self) - else: - raise ValueError('Unexpected subject class "{item.meaning}".') + self.append(authority_item) -class ObservationContext(Template): +class NormalRangeProperties(Template): - """:dcm:`TID 1001 ` - Observation Context""" + """:dcm:`TID 312 ` + Normal Range Properties""" def __init__( self, - observer_person_context: ObserverContext | None = None, - observer_device_context: ObserverContext | None = None, - subject_context: SubjectContext | None = None + values: Sequence[NumContentItem], + description: str | None = None, + authority: str | None = None ): """ Parameters ---------- - observer_person_context: Union[highdicom.sr.ObserverContext, None], optional - description of the person that reported the observation - observer_device_context: Union[highdicom.sr.ObserverContext, None], optional - description of the device that was involved in reporting the - observation - subject_context: Union[highdicom.sr.SubjectContext, None], optional - description of the imaging subject in case it is not the patient - for which the report is generated (e.g., a pathology specimen in - a whole-slide microscopy image, a fetus in an ultrasound image, or - a pacemaker device in a chest X-ray image) + values: Sequence[highdicom.sr.NumContentItem] + reference values of the normal range, e.g., its upper and lower + bound (see :dcm:`CID 223 ` + "Normal Range Values" for options) + description: Union[str, None], optional + description of the normal range + authority: Union[str, None], optional + authority for the description of the normal range """ # noqa: E501 super().__init__() - if observer_person_context is not None: - if not isinstance(observer_person_context, ObserverContext): - raise TypeError( - 'Argument "observer_person_context" must ' - f'have type {ObserverContext.__name__}' - ) - self.extend(observer_person_context) - if observer_device_context is not None: - if not isinstance(observer_device_context, ObserverContext): - raise TypeError( - 'Argument "observer_device_context" must ' - f'have type {ObserverContext.__name__}' - ) - self.extend(observer_device_context) - if subject_context is not None: - if not isinstance(subject_context, SubjectContext): - raise TypeError( - f'Argument "subject_context" must have ' - f'type {SubjectContext.__name__}' + if not isinstance(values, (list, tuple)): + raise TypeError('Argument "values" must be a list.') + for concept in values: + if not isinstance(concept, NumContentItem): + raise ValueError( + 'Items of argument "values" must have type ' + 'NumContentItem.' ) - self.extend(subject_context) + self.extend(values) + if description is not None: + description_item = TextContentItem( + name=codes.DCM.NormalRangeDescription, + value=description, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(description_item) + if authority is not None: + authority_item = TextContentItem( + name=codes.DCM.NormalRangeAuthority, + value=authority, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(authority_item) -class LanguageOfContentItemAndDescendants(Template): +class MeasurementProperties(Template): - """:dcm:`TID 1204 ` - Language of Content Item and Descendants""" + """:dcm:`TID 310 ` + Measurement Properties""" - def __init__(self, language: CodedConcept): + def __init__( + self, + normality: CodedConcept | Code | None = None, + level_of_significance: CodedConcept | Code | None = None, + selection_status: CodedConcept | Code | None = None, + measurement_statistical_properties: None | ( + MeasurementStatisticalProperties + ) = None, + normal_range_properties: NormalRangeProperties | None = None, + upper_measurement_uncertainty: int | float | None = None, + lower_measurement_uncertainty: int | float | None = None + ): """ Parameters ---------- - language: highdicom.sr.CodedConcept - language used for content items included in report + normality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + the extend to which the measurement is considered normal or abnormal + (see :dcm:`CID 222 ` "Normality Codes" for + options) + level_of_significance: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + the extend to which the measurement is considered normal or abnormal + (see :dcm:`CID 220 ` "Level of + Significance" for options) + selection_status: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + how the measurement value was selected or computed from a set of + available values (see :dcm:`CID 224 ` + "Selection Method" for options) + measurement_statistical_properties: Union[highdicom.sr.MeasurementStatisticalProperties, None], optional + statistical properties of a reference population for a measurement + and/or the position of a measurement in such a reference population + normal_range_properties: Union[highdicom.sr.NormalRangeProperties, None], optional + statistical properties of a reference population for a measurement + and/or the position of a measurement in such a reference population + upper_measurement_uncertainty: Union[int, float, None], optional + upper range of measurement uncertainty + lower_measurement_uncertainty: Union[int, float, None], optional + lower range of measurement uncertainty - """ + """ # noqa: E501 super().__init__() - language_item = CodeContentItem( - name=CodedConcept( - value='121049', - meaning='Language of Content Item and Descendants', - scheme_designator='DCM', - ), - value=language, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD - ) - self.append(language_item) + if normality is not None: + normality_item = CodeContentItem( + name=CodedConcept( + value='121402', + meaning='Normality', + scheme_designator='DCM' + ), + value=normality, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(normality_item) + if measurement_statistical_properties is not None: + if not isinstance(measurement_statistical_properties, + MeasurementStatisticalProperties): + raise TypeError( + 'Argument "measurement_statistical_properties" must have ' + 'type MeasurementStatisticalProperties.' + ) + self.extend(measurement_statistical_properties) + if normal_range_properties is not None: + if not isinstance(normal_range_properties, + NormalRangeProperties): + raise TypeError( + 'Argument "normal_range_properties" must have ' + 'type NormalRangeProperties.' + ) + self.extend(normal_range_properties) + if level_of_significance is not None: + level_of_significance_item = CodeContentItem( + name=CodedConcept( + value='121403', + meaning='Level of Significance', + scheme_designator='DCM' + ), + value=level_of_significance, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(level_of_significance_item) + if selection_status is not None: + selection_status_item = CodeContentItem( + name=CodedConcept( + value='121404', + meaning='Selection Status', + scheme_designator='DCM' + ), + value=selection_status, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(selection_status_item) + if upper_measurement_uncertainty is not None: + upper_measurement_uncertainty_item = NumContentItem( + name=CodedConcept( + value='371886008', + meaning='+, range of upper measurement uncertainty', + scheme_designator='SCT' + ), + value=upper_measurement_uncertainty, + unit=codes.UCUM.NoUnits, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(upper_measurement_uncertainty_item) + if lower_measurement_uncertainty is not None: + lower_measurement_uncertainty_item = NumContentItem( + name=CodedConcept( + value='371885007', + meaning='-, range of lower measurement uncertainty', + scheme_designator='SCT' + ), + value=lower_measurement_uncertainty, + unit=codes.UCUM.NoUnits, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.append(lower_measurement_uncertainty_item) class QualitativeEvaluation(Template): @@ -3335,9 +2059,9 @@ def __init__( group_item.ContentSequence.extend(referenced_volume_surface) elif referenced_segment is not None: if not isinstance( - referenced_segment, - (ReferencedSegment, ReferencedSegmentationFrame) - ): + referenced_segment, + (ReferencedSegment, ReferencedSegmentationFrame) + ): raise TypeError( 'Argument "referenced_segment" must have type ' 'ReferencedSegment or ' diff --git a/src/highdicom/sr/templates/tid2000.py b/src/highdicom/sr/templates/tid2000.py new file mode 100644 index 00000000..62296cbb --- /dev/null +++ b/src/highdicom/sr/templates/tid2000.py @@ -0,0 +1,322 @@ +"""Templates related to TID2000 Basic Diagnostic Imaging Report""" +from typing import Optional, Sequence, Union +from highdicom.sr.value_types import ( + Code, + CodeContentItem, + CodedConcept, + ContentSequence, + TextContentItem, + ContainerContentItem, + ImageContentItem, + RelationshipTypeValues +) +from highdicom.sr.templates.common import ( + LanguageOfValue, + Template, + LanguageOfContentItemAndDescendants, + ObservationContext +) +from pydicom.sr.codedict import codes + + +class EquivalentMeaningsOfConceptNameText(TextContentItem): + """:dcm:`TID 1210 ` + Equivalent Meaning(s) of Concept Name Text + """ + + def __init__( + self, + value: str, + language_of_value: Optional[LanguageOfValue] = None + ) -> None: + super().__init__( + name=codes.DCM.EquivalentMeaningOfConceptName, + value=value, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD) + content = ContentSequence() + if language_of_value is not None: + content.append(language_of_value) + if len(content) > 0: + self.ContentSequence = content + + +class EquivalentMeaningsOfConceptNameCode(CodeContentItem): + """:dcm:`TID 1210 ` + Equivalent Meaning(s) of Concept Name Code + """ + + def __init__( + self, + value: Union[Code, CodedConcept], + language_of_value: Optional[LanguageOfValue] = None + ) -> None: + super().__init__( + name=codes.DCM.EquivalentMeaningOfConceptName, + value=value, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD) + content = ContentSequence() + if language_of_value is not None: + content.append(language_of_value) + if len(content) > 0: + self.ContentSequence = content + + +class ReportNarrativeCode(CodeContentItem): + """:dcm:`TID 2002 ` + Report Narrative Code + """ + + def __init__( + self, + value: Union[Code, CodedConcept], + basic_diagnostic_imaging_report_observations: Optional[ + Sequence[ImageContentItem]] = None + ) -> None: + super().__init__( + name=CodedConcept( + value='7002', + meaning='Diagnostic Imaging Report Element', + scheme_designator='CID' + ), + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if not isinstance(basic_diagnostic_imaging_report_observations, ( + list, tuple, set + )): + raise TypeError( + 'Argument "basic_diagnostic_imaging_report_observations" ' + + 'must be a sequence.' + ) + for basic_diagnostic_imaging_report_observation in \ + basic_diagnostic_imaging_report_observations: + if not isinstance(basic_diagnostic_imaging_report_observation, + ImageContentItem): + raise TypeError( + 'Items of argument' + + ' "basic_diagnostic_imaging_report_observation" ' + + 'must have type ImageContentItem.' + ) + content.append(basic_diagnostic_imaging_report_observation) + if len(content) > 0: + self.ContentSequence = content + + +class ReportNarrativeText(TextContentItem): + """:dcm:`TID 2002 ` + Report Narrative Text + """ + + def __init__( + self, + value: str, + basic_diagnostic_imaging_report_observations: Optional[ + Sequence[ImageContentItem]] = None + ) -> None: + super().__init__( + name=CodedConcept( + value='7002', + meaning='Diagnostic Imaging Report Element', + scheme_designator='CID' + ), + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if not isinstance(basic_diagnostic_imaging_report_observations, ( + list, tuple, set + )): + raise TypeError( + 'Argument "basic_diagnostic_imaging_report_observations" ' + + 'must be a sequence.' + ) + for basic_diagnostic_imaging_report_observation in \ + basic_diagnostic_imaging_report_observations: + if not isinstance(basic_diagnostic_imaging_report_observation, + ImageContentItem): + raise TypeError( + 'Items of argument ' + + ' "basic_diagnostic_imaging_report_observation" ' + + 'must have type ImageContentItem.' + ) + content.append(basic_diagnostic_imaging_report_observation) + if len(content) > 0: + self.ContentSequence = content + + +class DiagnosticImagingReportHeading(Template): + """:dcm:`CID 7001 ` + Diagnostic Imaging Report Heading + """ + + def __init__( + self, + report_narrative: Union[ReportNarrativeCode, ReportNarrativeText], + observation_context: Optional[ObservationContext] = None + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='7001', + meaning='Diagnostic Imaging Report Heading', + scheme_designator='CID' + ), + template_id='7001', + relationship_type=RelationshipTypeValues.CONTAINS + ) + item.ContentSequence = ContentSequence() + if not isinstance(report_narrative, ( + ReportNarrativeCode, ReportNarrativeText + )): + raise TypeError( + 'Argument "report_narrative" must have type ' + + 'ReportNarrativeCode or ReportNarrativeText.' + ) + item.ContentSequence.append(report_narrative) + if observation_context is not None: + if not isinstance(observation_context, ObservationContext): + raise TypeError( + 'Argument "observation_context" must have type ' + + 'ObservationContext.' + ) + item.ContentSequence.extend(observation_context) + super().__init__([item]) + + +class BasicDiagnosticImagingReport(Template): + """:dcm:`TID 2000 ` + Basic Diagnostic Imaging Report + """ + + def __init__( + self, + language_of_content_item_and_descendants: + LanguageOfContentItemAndDescendants, + observation_context: ObservationContext, + procedures_reported: Optional[Sequence[ + Union[CodedConcept, Code]]] = None, + acquisition_device_types: Optional[Sequence[ + Union[CodedConcept, Code]]] = None, + target_regions: Optional[Sequence[ + Union[CodedConcept, Code]]] = None, + equivalent_meanings_of_concept_name: Optional[Sequence[ + Union[EquivalentMeaningsOfConceptNameText, + EquivalentMeaningsOfConceptNameCode]]] = None, + diagnostic_imaging_report_headings: Optional[Sequence[ + DiagnosticImagingReportHeading]] = None + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='7000', + meaning='Diagnostic Imaging Report Document Title', + scheme_designator='CID' + ), + template_id='2000' + ) + item.ContentSequence = ContentSequence() + if not isinstance(language_of_content_item_and_descendants, + LanguageOfContentItemAndDescendants): + raise TypeError( + 'Argument "language_of_content_item_and_descendants" must ' + + 'have type LanguageOfContentItemAndDescendants.' + ) + item.ContentSequence.extend(language_of_content_item_and_descendants) + if not isinstance(observation_context, ObservationContext): + raise TypeError( + 'Argument "observation_context" must have type ' + + 'ObservationContext.' + ) + item.ContentSequence.extend(observation_context) + if procedures_reported is not None: + if not isinstance(procedures_reported, (list, tuple, set)): + raise TypeError( + 'Argument "procedures_reported" must be a sequence.' + ) + for procedure_reported in procedures_reported: + if not isinstance(procedure_reported, (CodedConcept, Code)): + raise TypeError( + 'Items of argument "procedure_reported" must have ' + + 'type Code or CodedConcept.' + ) + procedure_reported_item = CodeContentItem( + name=codes.DCM.ProcedureReported, + value=procedure_reported, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + item.ContentSequence.append(procedure_reported_item) + if acquisition_device_types is not None: + if not isinstance(acquisition_device_types, (list, tuple, set)): + raise TypeError( + 'Argument "acquisition_device_types" must be a sequence.' + ) + for acquisition_device_type in acquisition_device_types: + if not isinstance(acquisition_device_type, ( + CodedConcept, Code + )): + raise TypeError( + 'Items of argument "acquisition_device_type" must ' + + 'have type Code or CodedConcept.' + ) + acquisition_device_type_item = CodeContentItem( + name=codes.DCM.AcquisitionDeviceType, + value=acquisition_device_type, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + item.ContentSequence.append(acquisition_device_type_item) + if target_regions is not None: + if not isinstance(target_regions, (list, tuple, set)): + raise TypeError( + 'Argument "target_regions" must be a sequence.' + ) + for target_region in target_regions: + if not isinstance(target_region, (CodedConcept, Code)): + raise TypeError( + 'Items of argument "target_region" must have type ' + + 'Code or CodedConcept.' + ) + target_region_item = CodeContentItem( + name=codes.DCM.TargetRegion, + value=target_region, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + item.ContentSequence.append(target_region_item) + if equivalent_meanings_of_concept_name is not None: + if not isinstance(equivalent_meanings_of_concept_name, ( + list, tuple, set + )): + raise TypeError( + 'Argument "equivalent_meanings_of_concept_name" ' + + 'must be a sequence.' + ) + for equivalent_meaning_of_concept_name in \ + equivalent_meanings_of_concept_name: + if not isinstance(equivalent_meaning_of_concept_name, ( + EquivalentMeaningsOfConceptNameCode, + EquivalentMeaningsOfConceptNameText + )): + raise TypeError( + 'Items of argument' + + ' "equivalent_meaning_of_concept_name" ' + + 'must have type EquivalentMeaningsOfConceptNameCode ' + + 'or EquivalentMeaningsOfConceptNameText.' + ) + item.ContentSequence.append(equivalent_meaning_of_concept_name) + if diagnostic_imaging_report_headings is not None: + if not isinstance(diagnostic_imaging_report_headings, ( + list, tuple, set + )): + raise TypeError( + 'Argument "diagnostic_imaging_report_headings" ' + + 'must be a sequence.' + ) + for diagnostic_imaging_report_heading in \ + diagnostic_imaging_report_headings: + if not isinstance(diagnostic_imaging_report_heading, + DiagnosticImagingReportHeading): + raise TypeError( + 'Items of argument' + + ' "diagnostic_imaging_report_heading" ' + + 'must have type DiagnosticImagingReportHeading.' + ) + item.ContentSequence.extend(diagnostic_imaging_report_heading) + super().__init__([item], is_root=True) diff --git a/src/highdicom/sr/templates/tid3700.py b/src/highdicom/sr/templates/tid3700.py new file mode 100644 index 00000000..68b465f8 --- /dev/null +++ b/src/highdicom/sr/templates/tid3700.py @@ -0,0 +1,1213 @@ +from datetime import datetime +from typing import Optional, Sequence, Union +from highdicom.sr.value_types import ( + Code, + CodeContentItem, + CodedConcept, + ContentSequence, + DateTimeContentItem, + NumContentItem, + TextContentItem, + CompositeContentItem, + ContainerContentItem, + RelationshipTypeValues, + TcoordContentItem, + UIDRefContentItem, + WaveformContentItem +) +from highdicom.sr.templates.common import ( + AgeUnit, + AlgorithmIdentification, + LanguageOfContentItemAndDescendants, + ObserverContext, + PersonObserverIdentifyingAttributes, + PressureUnit, + Template +) +from pydicom.valuerep import DT +from pydicom.sr.codedict import codes + +from highdicom.sr.templates.tid3802 import CardiovascularPatientHistory + +BPM = Code( + value='bpm', + scheme_designator='UCUM', + meaning='beats per minute', + scheme_version=None +) +BEATS = Code( + value='beats', + scheme_designator='UCUM', + meaning='beats', + scheme_version=None +) +KG = Code( + value='kg', + scheme_designator='UCUM', + meaning='kilogram', + scheme_version=None +) + + +class ECGWaveFormInformation(Template): + """:dcm:`TID 3708 ` + ECG Waveform Information + """ + + def __init__( + self, + procedure_datetime: Union[str, datetime, DT], + source_of_measurement: Optional[WaveformContentItem] = None, + lead_system: Optional[Union[CodedConcept, Code]] = None, + acquisition_device_type: Optional[str] = None, + equipment_identification: Optional[str] = None, + person_observer_identifying_attributes: Optional[ + PersonObserverIdentifyingAttributes] = None, + room_identification: Optional[str] = None, + ecg_control_numeric_variables: Optional[Sequence[float]] = None, + ecg_control_text_variables: Optional[Sequence[str]] = None, + algorithm_identification: Optional[AlgorithmIdentification] = None + ) -> None: + item = ContainerContentItem( + name=codes.LN.CurrentProcedureDescriptions, + template_id='3708', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + procedure_datetime_item = DateTimeContentItem( + name=codes.DCM.ProcedureDatetime, + value=procedure_datetime, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(procedure_datetime_item) + if source_of_measurement is not None: + if not isinstance(source_of_measurement, WaveformContentItem): + raise TypeError( + 'Argument "source_of_measurement" must have ' + + 'type WaveformContentItem.' + ) + content.append(source_of_measurement) + if lead_system is not None: + if not isinstance(lead_system, (CodedConcept, Code)): + raise TypeError( + 'Argument "lead_system" must have type ' + + 'Code or CodedConcept.' + ) + lead_system_item = CodeContentItem( + name=CodedConcept( + value='10:11345', + meaning='Lead System', + scheme_designator='MDC' + ), + value=lead_system, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(lead_system_item) + if acquisition_device_type is not None: + if not isinstance(acquisition_device_type, str): + raise TypeError( + 'Argument "acquisition_device_type" must have type str.' + ) + acquisition_device_type_item = TextContentItem( + name=codes.DCM.AcquisitionDeviceType, + value=acquisition_device_type, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(acquisition_device_type_item) + if equipment_identification is not None: + if not isinstance(equipment_identification, str): + raise TypeError( + 'Argument "equipment_identification" must have type str.' + ) + equipment_identification_item = TextContentItem( + name=codes.DCM.EquipmentIdentification, + value=equipment_identification, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(equipment_identification_item) + if person_observer_identifying_attributes is not None: + if not isinstance(person_observer_identifying_attributes, + PersonObserverIdentifyingAttributes): + raise TypeError( + 'Argument "person_observer_identifying_attributes" ' + + 'must have type PersonObserverIdentifyingAttributes.' + ) + content.extend(person_observer_identifying_attributes) + if room_identification is not None: + if not isinstance(room_identification, (list, tuple, set)): + raise TypeError( + 'Argument "room_identifications" must be a sequence.' + ) + room_identification_item = TextContentItem( + name=codes.DCM.RoomIdentification, + value=room_identification, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(room_identification_item) + if ecg_control_numeric_variables is not None: + if not isinstance(ecg_control_numeric_variables, ( + list, tuple, set + )): + raise TypeError( + 'Argument "ecg_control_numeric_variables" must be ' + + 'a sequence.' + ) + for ecg_control_numeric_variable in ecg_control_numeric_variables: + if not isinstance(ecg_control_numeric_variable, float): + raise TypeError( + 'Items of argument "ecg_control_numeric_variable" ' + + 'must have type float.' + ) + ecg_control_numeric_variable_item = NumContentItem( + name=CodedConcept( + value='3690', + meaning='ECG Control Numeric Variable', + scheme_designator='CID' + ), + value=ecg_control_numeric_variable, + unit=codes.UCUM.NoUnits, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_control_numeric_variable_item) + if ecg_control_text_variables is not None: + if not isinstance(ecg_control_text_variables, (list, tuple, set)): + raise TypeError( + 'Argument "ecg_control_text_variables" must be a sequence.' + ) + for ecg_control_text_variable in ecg_control_text_variables: + if not isinstance(ecg_control_text_variable, str): + raise TypeError( + 'Items of argument "ecg_control_numeric_variable" ' + + 'must have type str.' + ) + ecg_control_text_variable_item = TextContentItem( + name=CodedConcept( + value='3691', + meaning='ECG Control Text Variable', + scheme_designator='CID' + ), + value=ecg_control_text_variable, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_control_text_variable_item) + if algorithm_identification is not None: + if not isinstance(algorithm_identification, + AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_identification" must have type ' + + 'AlgorithmIdentification.' + ) + content.extend(algorithm_identification) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ECGMeasurementSource(Template): + """:dcm:`TID 3715 ` + ECG Measurement Source + """ + + def __init__( + self, + beat_number: Optional[str], + measurement_method: Optional[Union[Code, CodedConcept]], + source_of_measurement: Optional[TcoordContentItem] + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='3715', + meaning='ECG Measurement Source', + scheme_designator='TID' + ), + template_id='3715', + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT + ) + content = ContentSequence() + if not isinstance(beat_number, str): + raise TypeError( + 'Argument "beat_number" must have type str.' + ) + # TODO: Beat number str can be up to three numeric characters + beat_number_item = TextContentItem( + name=codes.DCM.BeatNumber, + value=beat_number, + # TODO: The Relationship Type is not defined in the DICOM template + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(beat_number_item) + if source_of_measurement is not None: + if not isinstance(source_of_measurement, TcoordContentItem): + raise TypeError( + 'Argument "source_of_measurement" must have type ' + + 'TcoordContentItem.' + ) + content.append(source_of_measurement) + if measurement_method is not None: + if not isinstance(measurement_method, (Code, CodedConcept)): + raise TypeError( + 'Argument "measurement_method" must be a ' + + 'Code or CodedConcept.' + ) + measurement_method_item = CodeContentItem( + name=codes.SCT.MeasurementMethod, + value=measurement_method, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content.append(measurement_method_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class QTcIntervalGlobal(NumContentItem): + """:mdc:`MDC 2:15876` + QTc interval global + """ + + def __init__( + self, + value: float, + algorithm_name: Optional[Union[Code, CodedConcept]] = None + ) -> None: + super().__init__( + name=codes.MDC.QtcIntervalGlobal, + value=value, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if algorithm_name is not None: + if not isinstance(algorithm_name, (Code, CodedConcept)): + raise TypeError( + 'Argument "algorithm_name" must have type ' + + 'Code or CodedConcept.' + ) + algorithm_name_item = CodeContentItem( + name=codes.DCM.AlgorithmName, + value=algorithm_name, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(algorithm_name_item) + if len(content) > 0: + self.ContentSequence = content + + +class NumberOfEctopicBeats(NumContentItem): + """:dcm:`DCM 122707` + Number of Ectopic Beats + """ + + def __init__( + self, + value: float, + associated_morphologies: Optional[Sequence[Union[Code, + CodedConcept]]] = None + ) -> None: + super().__init__( + name=codes.DCM.NumberOfEctopicBeats, + value=value, + unit=BEATS, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if associated_morphologies is not None: + if not isinstance(associated_morphologies, (list, tuple, set)): + raise TypeError( + 'Argument "associated_morphologies" must be a sequence.' + ) + for associated_morphology in associated_morphologies: + if not isinstance(associated_morphology, (Code, CodedConcept)): + raise TypeError( + 'Items of argument "associated_morphology" must ' + + 'have type Code or CodedConcept.' + ) + associated_morphology_item = CodeContentItem( + name=codes.SCT.AssociatedMorphology, + value=associated_morphology, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(associated_morphology_item) + if len(content) > 0: + self.ContentSequence = content + + +class ECGGlobalMeasurements(Template): + """:dcm:`TID 3713 ` + ECG Global Measurements + """ + + def __init__( + self, + ventricular_heart_rate: float, + qt_interval_global: float, + pr_interval_global: float, + qrs_duration_global: float, + rr_interval_global: float, + ecg_measurement_source: Optional[ECGMeasurementSource] = None, + atrial_heart_rate: Optional[float] = None, + qtc_interval_global: Optional[QTcIntervalGlobal] = None, + ecg_global_waveform_durations: Optional[Sequence[float]] = None, + ecg_axis_measurements: Optional[Sequence[float]] = None, + count_of_all_beats: Optional[float] = None, + number_of_ectopic_beats: Optional[NumberOfEctopicBeats] = None + ) -> None: + item = ContainerContentItem( + name=codes.DCM.ECGGlobalMeasurements, + template_id='3713', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if not isinstance(ventricular_heart_rate, float): + raise TypeError( + 'Argument "ventricular_heart_rate" must have type float.' + ) + ventricular_heart_rate_item = NumContentItem( + name=CodedConcept( + value='2:16016', + meaning='Ventricular Heart Rate', + scheme_designator='MDC' + ), + value=ventricular_heart_rate, + unit=BPM, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ventricular_heart_rate_item) + if not isinstance(qt_interval_global, float): + raise TypeError( + 'Argument "qt_interval_global" must have type float.' + ) + qt_interval_global_item = NumContentItem( + name=codes.MDC.QTIntervalGlobal, + value=qt_interval_global, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(qt_interval_global_item) + if not isinstance(pr_interval_global, float): + raise TypeError( + 'Argument "qt_interval_global" must have type float.' + ) + pr_interval_global_item = NumContentItem( + name=codes.MDC.PRIntervalGlobal, + value=pr_interval_global, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(pr_interval_global_item) + if not isinstance(qrs_duration_global, float): + raise TypeError( + 'Argument "qrs_duration_global" must have type float.' + ) + qrs_duration_global_item = NumContentItem( + name=codes.MDC.QRSDurationGlobal, + value=qrs_duration_global, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(qrs_duration_global_item) + if not isinstance(rr_interval_global, float): + raise TypeError( + 'Argument "rr_interval_global" must have type float.' + ) + rr_interval_global_item = NumContentItem( + name=codes.MDC.RRIntervalGlobal, + value=rr_interval_global, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(rr_interval_global_item) + if ecg_measurement_source is not None: + if not isinstance(ecg_measurement_source, ECGMeasurementSource): + raise TypeError( + 'Argument "ecg_measurement_source" must have type ' + + 'ECGMeasurementSource.' + ) + content.extend(ecg_measurement_source) + if atrial_heart_rate is not None: + if not isinstance(atrial_heart_rate, float): + raise TypeError( + 'Argument "atrial_heart_rate" must have type float.' + ) + atrial_heart_rate_item = NumContentItem( + name=CodedConcept( + value='2:16020', + meaning='Atrial Heart Rate', + scheme_designator='MDC' + ), + value=atrial_heart_rate, + unit=BPM, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(atrial_heart_rate_item) + if qtc_interval_global is not None: + if not isinstance(qtc_interval_global, QTcIntervalGlobal): + raise TypeError( + 'Argument "qtc_interval_global" must have type ' + + 'QTcIntervalGlobal.' + ) + content.append(qtc_interval_global) + if ecg_global_waveform_durations is not None: + if not isinstance(ecg_global_waveform_durations, ( + list, tuple, set + )): + raise TypeError( + 'Argument "ecg_global_waveform_durations" must ' + + 'be a sequence.' + ) + for ecg_global_waveform_duration in ecg_global_waveform_durations: + if not isinstance(ecg_global_waveform_duration, float): + raise TypeError( + 'Items of argument "ecg_global_waveform_duration" ' + + 'must have type float.' + ) + ecg_global_waveform_duration_item = NumContentItem( + name=CodedConcept( + value='3687', + meaning='ECG Global Waveform Duration', + scheme_designator='CID' + ), + value=ecg_global_waveform_duration, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_global_waveform_duration_item) + if ecg_axis_measurements is not None: + if not isinstance(ecg_axis_measurements, (list, tuple, set)): + raise TypeError( + 'Argument "ecg_axis_measurements" must be a sequence.' + ) + for ecg_axis_measurement in ecg_axis_measurements: + if not isinstance(ecg_axis_measurement, float): + raise TypeError( + 'Items of argument "ecg_axis_measurement" must ' + + 'have type float.' + ) + ecg_axis_measurement_item = NumContentItem( + name=CodedConcept( + value='3229', + meaning='ECG Axis Measurement', + scheme_designator='CID' + ), + value=ecg_axis_measurement, + unit=codes.UCUM.Degree, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_axis_measurement_item) + if count_of_all_beats is not None: + if not isinstance(count_of_all_beats, float): + raise TypeError( + 'Argument "count_of_all_beats" must have type float.' + ) + count_of_all_beats_item = NumContentItem( + name=CodedConcept( + value='2:16032', + meaning='Count of all beats', + scheme_designator='MDC' + ), + value=count_of_all_beats, + unit=BEATS, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(count_of_all_beats_item) + if number_of_ectopic_beats is not None: + if not isinstance(number_of_ectopic_beats, NumberOfEctopicBeats): + raise TypeError( + 'Argument "number_of_ectopic_beats" must have type NumberOfEctopicBeats.' + ) + content.append(number_of_ectopic_beats) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ECGLeadMeasurements(Template): + """:dcm:`TID 3714 ` + ECG Lead Measurements + """ + + def __init__( + self, + lead_id: Union[Code, CodedConcept], + ecg_measurement_source: Optional[ECGMeasurementSource] = None, + electrophysiology_waveform_durations: Optional[Sequence[float]] = None, + electrophysiology_waveform_voltages: Optional[Sequence[float]] = None, + st_segment_finding: Optional[Union[Code, CodedConcept]] = None, + findings: Optional[Sequence[Union[Code, CodedConcept]]] = None + ) -> None: + item = ContainerContentItem( + name=codes.DCM.ECGLeadMeasurements, + template_id='3714', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if not isinstance(lead_id, (Code, CodedConcept)): + raise TypeError( + 'Argument "lead_id" must be a Code or CodedConcept.' + ) + lead_id_item = CodeContentItem( + name=codes.DCM.LeadID, + value=lead_id, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content.append(lead_id_item) + if ecg_measurement_source is not None: + if not isinstance(ecg_measurement_source, ECGMeasurementSource): + raise TypeError( + 'Argument "ecg_measurement_source" must be a ' + + 'ECGMeasurementSource.' + ) + content.extend(ecg_measurement_source) + if electrophysiology_waveform_durations is not None: + if not isinstance(electrophysiology_waveform_durations, ( + list, tuple, set + )): + raise TypeError( + 'Argument "electrophysiology_waveform_durations" ' + + 'must be a sequence.' + ) + for electrophysiology_waveform_duration in \ + electrophysiology_waveform_durations: + if not isinstance(electrophysiology_waveform_duration, float): + raise TypeError( + 'Items of argument ' + + '"electrophysiology_waveform_duration" ' + + 'must have type float.' + ) + electrophysiology_waveform_duration_item = NumContentItem( + name=CodedConcept( + value='3687', + meaning='Electrophysiology Waveform Duration', + scheme_designator='CID' + ), + value=electrophysiology_waveform_duration, + unit=codes.UCUM.Millisecond, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(electrophysiology_waveform_duration_item) + if electrophysiology_waveform_voltages is not None: + if not isinstance(electrophysiology_waveform_voltages, ( + list, tuple, set + )): + raise TypeError( + 'Argument "electrophysiology_waveform_voltages" ' + + 'must be a sequence.' + ) + for electrophysiology_waveform_voltage in \ + electrophysiology_waveform_voltages: + if not isinstance(electrophysiology_waveform_voltage, float): + raise TypeError( + 'Items of argument' + + ' "electrophysiology_waveform_voltage" ' + + 'must have type float.' + ) + electrophysiology_waveform_voltage_item = NumContentItem( + name=CodedConcept( + value='3687', + meaning='Electrophysiology Waveform Duration', + scheme_designator='CID' + ), + value=electrophysiology_waveform_voltage, + unit=codes.UCUM.Millivolt, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(electrophysiology_waveform_voltage_item) + if st_segment_finding is not None: + if not isinstance(st_segment_finding, (Code, CodedConcept)): + raise TypeError( + 'Argument "st_segment_finding" must be a ' + + 'Code or CodedConcept.' + ) + st_segment_finding_item = CodeContentItem( + name=CodedConcept( + value='365416000', + meaning='ST Segment Finding', + scheme_designator='SCT' + ), + value=st_segment_finding, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(st_segment_finding_item) + if findings is not None: + if not isinstance(findings, (list, tuple, set)): + raise TypeError( + 'Argument "findings" must be a sequence.' + ) + for finding in findings: + if not isinstance(finding, (Code, CodedConcept)): + raise TypeError( + 'Items of argument "finding" must be a ' + + 'Code or CodedConcept.' + ) + finding_item = CodeContentItem( + name=codes.DCM.Finding, + value=finding, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(finding_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class QuantitativeAnalysis(Template): + """:dcm:`EV 122144` + Quantitative Analysis + """ + + def __init__( + self, + ecg_global_measurements: Optional[ECGGlobalMeasurements] = None, + ecg_lead_measurements: Optional[Sequence[ECGLeadMeasurements]] = None, + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='122144', + meaning='Quantitative Analysis', + scheme_designator='DCM' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if ecg_global_measurements is not None: + if not isinstance(ecg_global_measurements, ECGGlobalMeasurements): + raise TypeError( + 'Argument "ecg_global_measurements" must be a ' + + 'ECGGlobalMeasurements.' + ) + content.extend(ecg_global_measurements) + if ecg_lead_measurements is not None: + if not isinstance(ecg_lead_measurements, (list, tuple, set)): + raise TypeError( + 'Argument "ecg_lead_measurements" must be a sequence.' + ) + for ecg_lead_measurement in ecg_lead_measurements: + if not isinstance(ecg_lead_measurement, ECGLeadMeasurements): + raise TypeError( + 'Items of argument "ecg_lead_measurement" must ' + + 'have type ECGLeadMeasurements.' + ) + content.extend(ecg_lead_measurement) + item.ContentSequence = content + super().__init__([item]) + + +class IndicationsForProcedure(Template): + """:ln:`EV 18785-6` + Indications for Procedure + """ + + def __init__( + self, + findings: Optional[Sequence[Union[Code, CodedConcept]]] = None, + finding_text: Optional[str] = None + ) -> None: + item = ContainerContentItem( + name=codes.LN.IndicationsForProcedure, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if findings is not None: + if not isinstance(findings, (list, tuple, set)): + raise TypeError( + 'Argument "findings" must be a sequence.' + ) + for finding in findings: + if not isinstance(finding, (CodedConcept, Code, )): + raise TypeError( + 'Argument "findings" must have type ' + + 'Code or CodedConcept.' + ) + finding_item = CodeContentItem( + name=codes.DCM.Finding, + value=finding, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(finding_item) + if finding_text is not None: + if not isinstance(finding_text, str): + raise TypeError( + 'Argument "finding_text" must have type str.' + ) + finding_text_item = TextContentItem( + name=codes.DCM.Finding, + value=finding_text, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(finding_text_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class PatientCharacteristicsForECG(Template): + """:dcm:`TID 3704 ` + Patient Characteristics for ECG + """ + + def __init__( + self, + subject_age: AgeUnit, + subject_sex: str, + patient_height: Optional[float] = None, + patient_weight: Optional[float] = None, + systolic_blood_pressure: Optional[PressureUnit] = None, + diastolic_blood_pressure: Optional[PressureUnit] = None, + patient_state: Optional[Union[Code, CodedConcept]] = None, + pacemaker_in_situ: Optional[Union[Code, CodedConcept]] = None, + icd_in_situ: Optional[Union[Code, CodedConcept]] = None + ): + item = ContainerContentItem( + name=codes.DCM.PatientCharacteristics, + template_id='3704', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if not isinstance(subject_age, AgeUnit): + raise TypeError( + 'Argument "subject_age" must have type AgeUnit.' + ) + subject_age.add_items(content) + if not isinstance(subject_sex, str): + raise TypeError( + 'Argument "subject_sex" must have type str.' + ) + subject_sex_item = TextContentItem( + name=codes.DCM.SubjectSex, + value=subject_sex, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(subject_sex_item) + if patient_height is not None: + if not isinstance(patient_height, float): + raise TypeError( + 'Argument "patient_height" must have type float.' + ) + patient_height_item = NumContentItem( + name=CodedConcept( + value='8302-2', + meaning='Patient Height', + scheme_designator='LN' + ), + value=patient_height, + unit=codes.UCUM.Centimeter, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(patient_height_item) + if patient_weight is not None: + if not isinstance(patient_weight, float): + raise TypeError( + 'Argument "patient_weight" must have type float.' + ) + patient_weight_item = NumContentItem( + name=CodedConcept( + value='29463-7', + meaning='Patient Weight', + scheme_designator='LN' + ), + value=patient_weight, + unit=KG, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(patient_weight_item) + if systolic_blood_pressure is not None: + if not isinstance(systolic_blood_pressure, PressureUnit): + raise TypeError( + 'Argument "systolic_blood_pressure" must have ' + + 'type PressureUnit.' + ) + systolic_blood_pressure.name = codes.SCT.SystolicBloodPressure + systolic_blood_pressure.add_items(content) + if diastolic_blood_pressure is not None: + if not isinstance(diastolic_blood_pressure, PressureUnit): + raise TypeError( + 'Argument "diastolic_blood_pressure" must have ' + + 'type PressureUnit.' + ) + diastolic_blood_pressure.name = codes.SCT.DiastolicBloodPressure + diastolic_blood_pressure.add_items(content) + if patient_state is not None: + if not isinstance(patient_state, (CodedConcept, Code)): + raise TypeError( + 'Argument "patient_state" must have type ' + + 'Code or CodedConcept.' + ) + patient_state_item = CodeContentItem( + name=codes.DCM.PatientState, + value=patient_state, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(patient_state_item) + if pacemaker_in_situ is not None: + if not isinstance(pacemaker_in_situ, (CodedConcept, Code)): + raise TypeError( + 'Argument "pacemaker_in_situ" must have type ' + + 'Code or CodedConcept.' + ) + pacemaker_in_situ_item = CodeContentItem( + name=CodedConcept( + value='441509002', + meaning='Pacemaker in situ', + scheme_designator='SCT' + ), + value=pacemaker_in_situ, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(pacemaker_in_situ_item) + if icd_in_situ is not None: + if not isinstance(icd_in_situ, (CodedConcept, Code)): + raise TypeError( + 'Argument "icd_in_situ" must have type ' + + 'Code or CodedConcept.' + ) + icd_in_situ_item = CodeContentItem( + name=CodedConcept( + value='443325000', + meaning='ICD in situ', + scheme_designator='SCT' + ), + value=icd_in_situ, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(icd_in_situ_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class PriorECGStudy(Template): + """:dcm:`TID 3702 ` + Prior ECG Study + """ + + def __init__( + self, + comparison_with_prior_study_done: Union[CodedConcept, Code], + procedure_datetime: Optional[Union[str, datetime, DT]] = None, + procedure_study_instance_uid: Optional[UIDRefContentItem] = None, + prior_report_for_current_patient: Optional[CompositeContentItem] = None, + source_of_measurement: Optional[WaveformContentItem] = None + ): + item = ContainerContentItem( + name=codes.LN.PriorProcedureDescriptions, + template_id='3702', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + comparison_with_prior_study_done_item = CodeContentItem( + name=CodedConcept( + value='122140', + meaning='Comparison with Prior Study Done', + scheme_designator='DCM' + ), + value=comparison_with_prior_study_done, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(comparison_with_prior_study_done_item) + if procedure_datetime is not None: + if not isinstance(procedure_datetime, (str, datetime, DT)): + raise TypeError( + 'Argument "procedure_datetime" must have type ' + + 'str, datetime.datetime or DT.' + ) + procedure_datetime_item = DateTimeContentItem( + name=codes.DCM.ProcedureDatetime, + value=procedure_datetime, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(procedure_datetime_item) + if procedure_study_instance_uid is not None: + if not isinstance(procedure_study_instance_uid, + UIDRefContentItem): + raise TypeError( + 'Argument "procedure_stdy_instance_uid" must have ' + + 'type UIDRefContentItem.' + ) + content.append(procedure_study_instance_uid) + if prior_report_for_current_patient is not None: + if not isinstance(prior_report_for_current_patient, + CompositeContentItem): + raise TypeError( + 'Argument "prior_report_for_current_patient" must ' + + 'have type CompositeContentItem.' + ) + content.append(prior_report_for_current_patient) + if source_of_measurement is not None: + if not isinstance(source_of_measurement, WaveformContentItem): + raise TypeError( + 'Argument "source_of_measurement" must have type ' + + 'WaveformContentItem.' + ) + content.append(source_of_measurement) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ECGFinding(Template): + """:sct:`EV 271921002` + ECG Finding + """ + + def __init__( + self, + value: Union[CodedConcept, Code], + equivalent_meaning_of_value: Optional[str] = None, + ecg_findings: Optional[Sequence["ECGFinding"]] = None + ) -> None: + item = CodeContentItem( + name=CodedConcept( + value='271921002', + meaning='ECG Finding', + scheme_designator='SCT' + ), + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if equivalent_meaning_of_value is not None: + if not isinstance(equivalent_meaning_of_value, str): + raise TypeError( + 'Argument "equivalent_meaning_of_value" must have type str.' + ) + equivalent_meaning_of_value_item = TextContentItem( + name=codes.DCM.EquivalentMeaningOfValue, + value=equivalent_meaning_of_value, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content.append(equivalent_meaning_of_value_item) + if ecg_findings is not None: + if not isinstance(ecg_findings, (list, tuple, set)): + raise TypeError( + 'Argument "ecg_findings" must be a sequence.' + ) + for ecg_finding in ecg_findings: + if not isinstance(ecg_finding, ECGFinding): + raise TypeError( + 'Items of argument "ecg_finding" must have ' + + 'type ECGFinding.' + ) + content.extend(ecg_finding) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ECGQualitativeAnalysis(Template): + """:dcm:`TID 3717 ` + ECG Qualitative Analysis + """ + + def __init__( + self, + ecg_finding_text: Optional[str] = None, + ecg_finding_codes: Optional[Sequence[ECGFinding]] = None + ) -> None: + item = ContainerContentItem( + name=codes.DCM.QualitativeAnalysis, + template_id='3717', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if ecg_finding_text is None and ecg_finding_codes is None: + raise ValueError( + 'Either argument "ecg_finding_text" or "ECG_finding_code" ' + + 'must be given.' + ) + if ecg_finding_text is not None: + if not isinstance(ecg_finding_text, str): + raise TypeError( + 'Argument "ecg_finding_text" must have type str.' + ) + ecg_finding_text_item = TextContentItem( + name=CodedConcept( + value='271921002', + meaning='ECG Finding', + scheme_designator='SCT' + ), + value=ecg_finding_text, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_finding_text_item) + if ecg_finding_codes is not None: + if not isinstance(ecg_finding_codes, (list, tuple, set)): + raise TypeError( + 'Argument "ecg_finding_code" must be a sequence.' + ) + for ecg_finding_code in ecg_finding_codes: + if not isinstance(ecg_finding_code, ECGFinding): + raise TypeError( + 'Items of argument "ecg_finding" must have ' + + 'type ECGFinding.' + ) + content.extend(ecg_finding_code) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class SummaryECG(Template): + """:dcm:`TID 3719 ` + Summary, ECG + """ + + def __init__( + self, + summary: Optional[str] = None, + ecg_overall_finding: Optional[Union[Code, CodedConcept]] = None + ) -> None: + item = ContainerContentItem( + name=codes.LN.Summary, + template_id='3719', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if summary is not None: + if not isinstance(summary, str): + raise TypeError( + 'Argument "summary" must have type str.' + ) + summary_item = TextContentItem( + name=codes.LN.Summary, + value=summary, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(summary_item) + if ecg_overall_finding is not None: + if not isinstance(ecg_overall_finding, (Code, CodedConcept)): + raise TypeError( + 'Argument "ECG_overall_finding" must have type ' + + 'Code or CodedConcept.' + ) + ecg_overall_finding_item = CodeContentItem( + name=CodedConcept( + value='18810-2', + meaning='ECG overall finding', + scheme_designator='LN' + ), + value=ecg_overall_finding, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(ecg_overall_finding_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ECGReport(Template): + """:dcm:`TID 3700 ` + ECG Report + """ + + def __init__( + self, + language_of_content_item_and_descendants: + LanguageOfContentItemAndDescendants, + observer_contexts: Sequence[ObserverContext], + ecg_waveform_information: ECGWaveFormInformation, + quantitative_analysis: QuantitativeAnalysis, + procedure_reported: Optional[ + Union[CodedConcept, Code]] = None, + indications_for_procedure: Optional[ + IndicationsForProcedure] = None, + cardiovascular_patient_history: Optional[ + CardiovascularPatientHistory] = None, + patient_characteristics_for_ecg: Optional[ + PatientCharacteristicsForECG] = None, + prior_ecg_study: Optional[PriorECGStudy] = None, + ecg_qualitative_analysis: Optional[ECGQualitativeAnalysis] = None, + summary_ecg: Optional[SummaryECG] = None + ) -> None: + item = ContainerContentItem( + name=codes.LN.ECGReport, + template_id='3700' + ) + content = ContentSequence() + if not isinstance(language_of_content_item_and_descendants, + LanguageOfContentItemAndDescendants): + raise TypeError( + 'Argument "language_of_content_item_and_descendants" must ' + + 'have type LanguageOfContentItemAndDescendants.' + ) + content.extend(language_of_content_item_and_descendants) + if not isinstance(observer_contexts, (list, tuple, set)): + raise TypeError( + 'Argument "observer_contexts" must be a sequence.' + ) + for observer_context in observer_contexts: + if not isinstance(observer_context, ObserverContext): + raise TypeError( + 'Items of argument "observer_context" must have ' + + 'type ObserverContext.' + ) + content.extend(observer_context) + if not isinstance(ecg_waveform_information, ECGWaveFormInformation): + raise TypeError( + 'Argument "ecg_waveform_information" must have type ' + + 'ECGWaveFormInformation.' + ) + content.extend(ecg_waveform_information) + if not isinstance(quantitative_analysis, QuantitativeAnalysis): + raise TypeError( + 'Argument "quantitative_analysis" must have type ' + + 'QuantitativeAnalysis.' + ) + content.extend(quantitative_analysis) + if procedure_reported is not None: + if not isinstance(procedure_reported, (CodedConcept, Code)): + raise TypeError( + 'Argument "procedure_reported" must have type ' + + 'Code or CodedConcept.' + ) + procedure_reported_item = CodeContentItem( + name=codes.DCM.ProcedureReported, + value=procedure_reported, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content.append(procedure_reported_item) + if indications_for_procedure is not None: + if not isinstance(indications_for_procedure, + IndicationsForProcedure): + raise TypeError( + 'Argument "indications_for_procedure" must have ' + + 'type IndicationsForProcedure.' + ) + content.extend(indications_for_procedure) + if cardiovascular_patient_history is not None: + if not isinstance(cardiovascular_patient_history, + CardiovascularPatientHistory): + raise TypeError( + 'Argument "cardiovascular_patient_history" must have ' + + 'type CardiovascularPatientHistory.' + ) + content.extend(cardiovascular_patient_history) + if patient_characteristics_for_ecg is not None: + if not isinstance(patient_characteristics_for_ecg, + PatientCharacteristicsForECG): + raise TypeError( + 'Argument "patient_characteristics_for_ecg" must have ' + + 'type PatientCharacteristicsForECG.' + ) + content.extend(patient_characteristics_for_ecg) + if prior_ecg_study is not None: + if not isinstance(prior_ecg_study, PriorECGStudy): + raise TypeError( + 'Argument "prior_ecg_study" must have type PriorECGStudy.' + ) + content.extend(prior_ecg_study) + if ecg_qualitative_analysis is not None: + if not isinstance(ecg_qualitative_analysis, ECGQualitativeAnalysis): + raise TypeError( + 'Argument "ecg_qualitative_analysis" must have type ' + + 'ECGQualitativeAnalysis.' + ) + content.extend(ecg_qualitative_analysis) + if summary_ecg is not None: + if not isinstance(summary_ecg, SummaryECG): + raise TypeError( + 'Argument "summary_ecg" must have type SummaryECG.' + ) + content.extend(summary_ecg) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item], is_root=True) diff --git a/src/highdicom/sr/templates/tid3802.py b/src/highdicom/sr/templates/tid3802.py new file mode 100644 index 00000000..fb499930 --- /dev/null +++ b/src/highdicom/sr/templates/tid3802.py @@ -0,0 +1,988 @@ +from datetime import datetime +from typing import Optional, Sequence, Union + +from pydicom.sr.codedict import codes +from pydicom.valuerep import DT + +from highdicom.sr.value_types import ( + Code, + CodeContentItem, + CodedConcept, + DateTimeContentItem, + NumContentItem, + TextContentItem, + CompositeContentItem, + ContainerContentItem, + PnameContentItem, + RelationshipTypeValues +) +from highdicom.sr.templates.common import Template + +from highdicom.sr.value_types import ( + ContentSequence +) + +MILLIGRAM_PER_DECILITER = Code( + value='mg/dL', + scheme_designator='UCUM', + meaning='milligram per deciliter', + scheme_version=None +) + + +class Therapy(CodeContentItem): + """:sct:`277132007` + Therapy + """ + + def __init__( + self, + value: Union[Code, CodedConcept], + status: Optional[Union[Code, CodedConcept]] = None + ) -> None: + super().__init__( + name=CodedConcept( + value='277132007', + meaning='Therapy', + scheme_designator='SCT' + ), + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if status is not None: + status_item = CodeContentItem( + name=CodedConcept( + value='33999-4', + meaning='Status', + scheme_designator='LN' + ), + value=status, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(status_item) + if len(content) > 0: + self.ContentSequence = content + + +class ProblemProperties(Template): + """:dcm:`TID 3829 ` + Problem Properties + """ + + def __init__( + self, + # TODO: Concern Type should be a class for itself + concern_type: Union[Code, CodedConcept], + datetime_concern_noted: Optional[Union[str, datetime, DT]] = None, + datetime_concern_resolved: Optional[Union[str, datetime, DT]] = None, + health_status: Optional[Union[Code, CodedConcept]] = None, + therapies: Optional[Sequence[Therapy]] = None, + comment: Optional[str] = None + ) -> None: + item = ContainerContentItem( + name=codes.DCM.Concern, + template_id='3829', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + concern_item = CodeContentItem( + name=CodedConcept( + value='3769', + meaning='Concern Type', + scheme_designator='CID' + ), + value=concern_type, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(concern_item) + if datetime_concern_noted is not None: + datetime_concern_noted_item = DateTimeContentItem( + name=codes.DCM.DatetimeConcernNoted, + value=datetime_concern_noted, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(datetime_concern_noted_item) + if datetime_concern_resolved is not None: + datetime_concern_resolved_item = DateTimeContentItem( + name=codes.DCM.DatetimeConcernResolved, + value=datetime_concern_resolved, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(datetime_concern_resolved_item) + if health_status is not None: + health_status_item = CodeContentItem( + name=CodedConcept( + value='11323-3', + meaning='Health status', + scheme_designator='LN' + ), + value=health_status, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(health_status_item) + if therapies is not None: + if not isinstance(therapies, (list, tuple, set)): + raise TypeError( + 'Argument "therapies" must be a sequence.' + ) + for therapy in therapies: + if not isinstance(therapy, Therapy): + raise TypeError( + 'Items of argument "therapies" must have type Therapy.' + ) + content.append(therapy) + if comment is not None: + comment_item = TextContentItem( + name=codes.DCM.Comment, + value=comment, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(comment_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ProblemList(Template): + """:ln:`11450-4` + Problem List + """ + + def __init__( + self, + concern_types: Optional[ + Sequence[str]] = None, + cardiac_patient_risk_factors: Optional[ + Sequence[ProblemProperties]] = None, + history_of_diabetes_mellitus: Optional[ + ProblemProperties] = None, + history_of_hypertension: Optional[ + ProblemProperties] = None, + history_of_hypercholesterolemia: Optional[ + ProblemProperties] = None, + arrhythmia: Optional[ + ProblemProperties] = None, + history_of_myocardial_infarction: Optional[ + ProblemProperties] = None, + history_of_kidney_disease: Optional[ + ProblemProperties] = None + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='11450-4', + meaning='Problem List', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if concern_types is not None: + if not isinstance(concern_types, (list, tuple, set)): + raise TypeError( + 'Argument "concern_types" must be a sequence.' + ) + for concern_type in concern_types: + if not isinstance(concern_type, str): + raise TypeError( + 'Items of argument "concern_types" must have type str.' + ) + concern_type_item = TextContentItem( + name=CodedConcept( + value='3769', + meaning='Concern Type', + scheme_designator='CID' + ), + value=concern_type, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(concern_type_item) + if cardiac_patient_risk_factors is not None: + if not isinstance(cardiac_patient_risk_factors, ( + list, tuple, set + )): + raise TypeError( + 'Argument "cardiac_patient_risk_factors" must ' + + 'be a sequence.' + ) + for cardiac_patient_risk_factor in cardiac_patient_risk_factors: + if not isinstance(cardiac_patient_risk_factor, + ProblemProperties): + raise TypeError( + 'Items of argument "cardiac_patient_risk_factors" ' + + 'must have type ProblemProperties.' + ) + content.extend(cardiac_patient_risk_factor) + if history_of_diabetes_mellitus is not None: + if not isinstance(history_of_diabetes_mellitus, ProblemProperties): + raise TypeError( + 'Argument "history_of_diabetes_mellitus" must ' + + 'be a ProblemProperties.' + ) + content.extend(history_of_diabetes_mellitus) + if history_of_hypertension is not None: + if not isinstance(history_of_hypertension, ProblemProperties): + raise TypeError( + 'Argument "history_of_hypertension" must ' + + 'be a ProblemProperties.' + ) + content.extend(history_of_hypertension) + if history_of_hypercholesterolemia is not None: + if not isinstance(history_of_hypercholesterolemia, + ProblemProperties): + raise TypeError( + 'Argument "history_of_hypercholesterolemia" must ' + + 'be a ProblemProperties.' + ) + content.extend(history_of_hypercholesterolemia) + if arrhythmia is not None: + if not isinstance(arrhythmia, ProblemProperties): + raise TypeError( + 'Argument "arrhythmia" must be a ProblemProperties.' + ) + content.extend(arrhythmia) + if history_of_myocardial_infarction is not None: + if not isinstance(history_of_myocardial_infarction, + ProblemProperties): + raise TypeError( + 'Argument "history_of_myocardial_infarction" ' + + 'must be a ProblemProperties.' + ) + content.extend(history_of_myocardial_infarction) + if history_of_kidney_disease is not None: + if not isinstance(history_of_kidney_disease, ProblemProperties): + raise TypeError( + 'Argument "history_of_kidney_disease" must ' + + 'be a ProblemProperties.' + ) + content.extend(history_of_kidney_disease) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class SocialHistory(Template): + """:ln:`29762-2` + Social History + """ + + def __init__( + self, + social_history: Optional[str] = None, + social_histories: Optional[Sequence[str]] = None, + tobacco_smoking_behavior: Optional[ + Union[Code, CodedConcept]] = None, + drug_misuse_behavior: Optional[Union[Code, + CodedConcept]] = None, + ) -> None: + item = ContainerContentItem( + name=CodedConcept( + value='29762-2', + meaning='Social History', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if social_history is not None: + social_history_item = TextContentItem( + name=CodedConcept( + value='160476009', + meaning='Social History', + scheme_designator='SCT' + ), + value=social_history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(social_history_item) + if social_histories is not None: + if not isinstance(social_histories, (list, tuple, set)): + raise TypeError( + 'Argument "social_histories" must be a sequence.' + ) + for history in social_histories: + if not isinstance(history, str): + raise TypeError( + 'Items of argument "social_histories" must ' + + 'have type str.' + ) + social_history_item = TextContentItem( + name=CodedConcept( + value='3774', + meaning='Social History', + scheme_designator='CID' + ), + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(social_history_item) + if tobacco_smoking_behavior is not None: + tobacco_smoking_behavior_item = CodeContentItem( + name=codes.SCT.TobaccoSmokingBehavior, + value=tobacco_smoking_behavior, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(tobacco_smoking_behavior_item) + if drug_misuse_behavior is not None: + drug_misuse_behavior_item = CodeContentItem( + name=codes.SCT.DrugMisuseBehavior, + value=drug_misuse_behavior, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(drug_misuse_behavior_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class ProcedureProperties(Template): + """:dcm:`TID 3830 ` + Procedure Properties + """ + + def __init__( + self, + name: Union[CodedConcept, Code], + value: Union[CodedConcept, Code], + procedure_datetime: Optional[ + Union[str, datetime, DT]] = None, + clinical_reports: Optional[ + Sequence[CompositeContentItem]] = None, + clinical_reports_text: Optional[Sequence[str]] = None, + service_delivery_location: Optional[str] = None, + service_performer_person: Optional[str] = None, + service_performer_organisation: Optional[str] = None, + comment: Optional[str] = None, + procedure_results: Optional[ + Sequence[Union[Code, CodedConcept]]] = None + ) -> None: + item = CodeContentItem( + name=name, + value=value, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content = ContentSequence() + if procedure_datetime is not None: + procedure_datetime_item = DateTimeContentItem( + name=codes.DCM.ProcedureDatetime, + value=procedure_datetime, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(procedure_datetime_item) + if clinical_reports is not None: + if not isinstance(clinical_reports, (list, tuple, set)): + raise TypeError( + 'Argument "clinical_reports" must be a sequence.' + ) + for clinical_report in clinical_reports: + if not isinstance(clinical_report, CompositeContentItem): + raise TypeError( + 'Items of argument "clinical_reports" must ' + + 'have type CompositeContentItem.' + ) + content.append(clinical_report) + if clinical_reports_text is not None: + if not isinstance(clinical_reports_text, (list, tuple, set)): + raise TypeError( + 'Argument "clinical_reports_text" must be a sequence.' + ) + for clinical_report in clinical_reports_text: + if not isinstance(clinical_report, str): + raise TypeError( + 'Items of argument "clinical_reports" must ' + + 'have type str.' + ) + clinical_report_item = TextContentItem( + name=codes.SCT.ClinicalReport, + value=clinical_report, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(clinical_report_item) + if service_delivery_location is not None: + service_delivery_location_item = TextContentItem( + name=codes.DCM.ServiceDeliveryLocation, + value=service_delivery_location, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(service_delivery_location_item) + if service_performer_person is not None: + service_performer_person_item = PnameContentItem( + name=codes.DCM.ServicePerformer, + value=service_performer_person, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(service_performer_person_item) + if service_performer_organisation is not None: + service_performer_organisation_item = TextContentItem( + name=codes.DCM.ServicePerformer, + value=service_performer_organisation, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(service_performer_organisation_item) + if comment is not None: + comment_item = TextContentItem( + name=codes.DCM.Comment, + value=comment, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(comment_item) + if procedure_results is not None: + if not isinstance(procedure_results, (list, tuple, set)): + raise TypeError( + 'Argument "procedure_results" must be a sequence.' + ) + for procedure_result in procedure_results: + if not isinstance(procedure_result, (Code, CodedConcept)): + raise TypeError( + 'Items of argument "clinical_reports" must have ' + + 'type Code or CodedConcept.' + ) + procedure_result_item = CodeContentItem( + name=codes.DCM.ProcedureResult, + value=procedure_result, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(procedure_result_item) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) + + +class PastSurgicalHistory(Template): + """:ln:`10167-5` + Past Surgical History + """ + + def __init__( + self, + histories: Optional[Sequence[str]] = None, + procedure_properties: Optional[ + Sequence[ProcedureProperties]] = None + ) -> None: + super().__init__() + item = ContainerContentItem( + name=CodedConcept( + value='10167-5', + meaning='Past Surgical History', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if histories is not None: + if not isinstance(histories, (list, tuple, set)): + raise TypeError( + 'Argument "histories" must be a sequence.' + ) + for history in histories: + if not isinstance(history, str): + raise TypeError( + 'Items of argument "social_histories" must ' + + 'have type str.' + ) + history_item = TextContentItem( + name=codes.LN.History, + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(history_item) + if procedure_properties is not None: + if not isinstance(procedure_properties, (list, tuple, set)): + raise TypeError( + 'Argument "procedure_properties" must be a sequence.' + ) + for procedure_property in procedure_properties: + if not isinstance(procedure_property, ProcedureProperties): + raise TypeError( + 'Items of argument "procedure_properties" must ' + + 'have type ProcedureProperties.' + ) + content.extend(procedure_property) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class RelevantDiagnosticTestsAndOrLaboratoryData(Template): + """:ln:`30954-2` + Relevant Diagnostic Tests and/or Laboratory Data + """ + + def __init__( + self, + histories: Optional[Sequence[str]] = None, + procedure_properties: Optional[ + Sequence[ProcedureProperties]] = None, + cholesterol_in_HDL: Optional[float] = None, + cholesterol_in_LDL: Optional[float] = None + ) -> None: + super().__init__() + item = ContainerContentItem( + name=codes.LN.RelevantDiagnosticTestsAndOrLaboratoryData, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if histories is not None: + if not isinstance(histories, (list, tuple, set)): + raise TypeError( + 'Argument "histories" must be a sequence.' + ) + for history in histories: + if not isinstance(history, str): + raise TypeError( + 'Items of argument "social_histories" must ' + + 'have type str.' + ) + history_item = TextContentItem( + name=codes.LN.History, + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(history_item) + if procedure_properties is not None: + if not isinstance(procedure_properties, (list, tuple, set)): + raise TypeError( + 'Argument "procedure_properties" must be a sequence.' + ) + for procedure_property in procedure_properties: + if not isinstance(procedure_property, ProcedureProperties): + raise TypeError( + 'Items of argument "procedure_properties" must ' + + 'have type ProcedureProperties.' + ) + content.extend(procedure_property) + if cholesterol_in_HDL is not None: + cholesterol_in_HDL_item = NumContentItem( + name=CodedConcept( + value='2086-7', + meaning='Cholesterol in HDL', + scheme_designator='LN' + ), + value=cholesterol_in_HDL, + unit=MILLIGRAM_PER_DECILITER, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(cholesterol_in_HDL_item) + if cholesterol_in_LDL is not None: + cholesterol_in_LDL_item = NumContentItem( + name=CodedConcept( + value='2089-1', + meaning='Cholesterol in LDL', + scheme_designator='LN' + ), + value=cholesterol_in_LDL, + unit=MILLIGRAM_PER_DECILITER, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(cholesterol_in_LDL_item) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class MedicationTypeText(TextContentItem): + """:dcm:`DT 111516 ` + Medication Type Text + """ + + def __init__( + self, + value: str, + status: Optional[Union[Code, CodedConcept]] + ) -> None: + super().__init__( + name=codes.DCM.MedicationType, + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if status is not None: + status_item = CodeContentItem( + name=CodedConcept( + value='33999-4', + meaning='Status', + scheme_designator='LN' + ), + value=status, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(status_item) + if len(content) > 0: + self.ContentSequence = content + + +class MedicationTypeCode(CodeContentItem): + """:dcm:`DT 111516 ` + Medication Type Code + """ + + def __init__( + self, + value: Union[Code, CodedConcept], + dosage: Optional[float], + status: Optional[Union[Code, CodedConcept]] + ) -> None: + super().__init__( + name=codes.DCM.MedicationType, + value=value, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if dosage is not None: + dosage_item = NumContentItem( + name=codes.SCT.Dosage, + value=dosage, + unit=codes.UCUM.NoUnits, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(dosage_item) + if status is not None: + status_item = CodeContentItem( + name=CodedConcept( + value='33999-4', + meaning='Status', + scheme_designator='LN' + ), + value=status, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(status_item) + if len(content) > 0: + self.ContentSequence = content + + +class HistoryOfMedicationUse(Template): + """:ln:`10160-0` + History of Medication Use + """ + + def __init__( + self, + medication_types_text: Optional[ + Sequence[MedicationTypeText]] = None, + medication_types_code: Optional[ + Sequence[MedicationTypeCode]] = None, + ) -> None: + super().__init__() + item = ContainerContentItem( + name=CodedConcept( + value='10160-0', + meaning='History of Medication Use', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if medication_types_text is not None: + if not isinstance(medication_types_text, (list, tuple, set)): + raise TypeError( + 'Argument "medication_types_text" must be a sequence.' + ) + for medication_type_text in medication_types_text: + if not isinstance(medication_type_text, MedicationTypeText): + raise TypeError( + 'Items of argument "medication_types_text" must ' + + 'have type MedicationTypeText.' + ) + content.append(medication_type_text) + if medication_types_code is not None: + if not isinstance(medication_types_code, (list, tuple, set)): + raise TypeError( + 'Argument "medication_types_code" must be a sequence.' + ) + for medication_type_code in medication_types_code: + if not isinstance(medication_type_code, MedicationTypeCode): + raise TypeError( + 'Items of argument "medication_types_code" must ' + + 'have type MedicationTypeCode.' + ) + content.append(medication_type_code) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class FamilyHistoryOfClinicalFinding(CodeContentItem): + """:sct:`416471007` + Family history of clinical finding + """ + + def __init__( + self, + value: Union[Code, CodedConcept], + subject_relationship: Union[Code, CodedConcept] + ) -> None: + super().__init__( + name=codes.SCT.FamilyHistoryOfClinicalFinding, + value=value, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD + ) + content = ContentSequence() + subject_relationship_item = CodeContentItem( + name=CodedConcept( + value='408732007', + meaning='Subject relationship', + scheme_designator='SCT' + ), + value=subject_relationship, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(subject_relationship_item) + if len(content) > 0: + self.ContentSequence = content + + +class HistoryOfFamilyMemberDiseases(Template): + """:ln:`10157-6` + History of Family Member Diseases + """ + + def __init__( + self, + histories: Optional[Sequence[str]] = None, + family_histories_of_clinical_findings: Optional[ + Sequence[FamilyHistoryOfClinicalFinding]] = None + ) -> None: + super().__init__() + item = ContainerContentItem( + name=CodedConcept( + value='10157-6', + meaning='History of Family Member Diseases', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if histories is not None: + if not isinstance(histories, (list, tuple, set)): + raise TypeError( + 'Argument "histories" must be a sequence.' + ) + for history in histories: + if not isinstance(history, str): + raise TypeError( + 'Items of argument "social_histories" must ' + + 'have type str.' + ) + history_item = TextContentItem( + name=codes.LN.History, + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(history_item) + if family_histories_of_clinical_findings is not None: + if not isinstance(family_histories_of_clinical_findings, ( + list, tuple, set + )): + raise TypeError( + 'Argument "family_histories_of_clinical_findings" ' + + 'must be a sequence.' + ) + for family_history_of_clinical_finding in \ + family_histories_of_clinical_findings: + if not isinstance(family_history_of_clinical_finding, + FamilyHistoryOfClinicalFinding): + raise TypeError( + 'Items of argument ' + + '"family_histories_of_clinical_findings" ' + + 'must have type FamilyHistoryOfClinicalFinding.' + ) + content.append(family_history_of_clinical_finding) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class MedicalDeviceUse(Template): + """:dcm:`CID 3831 ` + Medical Device Use + """ + + def __init__( + self, + datetime_started: Optional[ + Union[str, datetime, DT]] = None, + datetime_ended: Optional[ + Union[str, datetime, DT]] = None, + status: Optional[Union[Code, CodedConcept]] = None, + comment: Optional[str] = None + ) -> None: + super().__init__() + item = ContainerContentItem( + name=CodedConcept( + value='46264-8', + meaning='Medical Device use', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if datetime_started is not None: + datetime_started_item = DateTimeContentItem( + name=codes.DCM.DatetimeStarted, + value=datetime_started, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(datetime_started_item) + if datetime_ended is not None: + datetime_ended_item = DateTimeContentItem( + name=codes.DCM.DatetimeEnded, + value=datetime_ended, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(datetime_ended_item) + if status is not None: + status_item = CodeContentItem( + name=CodedConcept( + value='33999-4', + meaning='Status', + scheme_designator='LN' + ), + value=status, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(status_item) + if comment is not None: + comment_item = TextContentItem( + name=codes.DCM.Comment, + value=comment, + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + content.append(comment_item) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class HistoryOfMedicalDeviceUse(Template): + """:ln:`46264-8` + History of medical device use + """ + + def __init__( + self, + history: Optional[str] = None, + medical_device_uses: Optional[ + Sequence[MedicalDeviceUse]] = None, + ) -> None: + super().__init__() + item = ContainerContentItem( + name=CodedConcept( + value='46264-8', + meaning='History of medical device use', + scheme_designator='LN' + ), + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if history is not None: + history_item = TextContentItem( + name=codes.LN.History, + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(history_item) + if medical_device_uses is not None: + if not isinstance(medical_device_uses, (list, tuple, set)): + raise TypeError( + 'Argument "medical_device_uses" must be a sequence.' + ) + for medical_device_use in medical_device_uses: + if not isinstance(medical_device_use, MedicalDeviceUse): + raise TypeError( + 'Items of argument "medical_device_uses" must have ' + + 'type MedicalDeviceUse.' + ) + content.extend(medical_device_use) + if len(content) > 0: + item.ContentSequence = content + self.append(item) + + +class CardiovascularPatientHistory(Template): + """:dcm:`TID 3802 ` + Cardiovascular Patient History + """ + + def __init__( + self, + history: Optional[str] = None, + problem_list: Optional[ProblemList] = None, + social_history: Optional[SocialHistory] = None, + past_surgical_history: Optional[PastSurgicalHistory] = None, + relevant_diagnostic_tests_and_or_laboratory_data: Optional[ + RelevantDiagnosticTestsAndOrLaboratoryData] = None, + history_of_medication_use: Optional[HistoryOfMedicationUse] = None, + history_of_family_member_diseases: Optional[ + HistoryOfFamilyMemberDiseases] = None, + history_of_medical_device_use: Optional[ + HistoryOfMedicalDeviceUse] = None + ) -> None: + item = ContainerContentItem( + name=codes.LN.History, + template_id='3802', + relationship_type=RelationshipTypeValues.CONTAINS + ) + content = ContentSequence() + if history is not None: + history_item = TextContentItem( + name=codes.LN.History, + value=history, + relationship_type=RelationshipTypeValues.CONTAINS + ) + content.append(history_item) + if problem_list is not None: + if not isinstance(problem_list, ProblemList): + raise TypeError( + 'Argument "problem_list" must be a ProblemList.' + ) + content.extend(problem_list) + if social_history is not None: + if not isinstance(social_history, SocialHistory): + raise TypeError( + 'Argument "social_history" must be a SocialHistory.' + ) + content.extend(social_history) + if past_surgical_history is not None: + if not isinstance(past_surgical_history, PastSurgicalHistory): + raise TypeError( + 'Argument "past_surgical_history" must be a ' + + 'PastSurgicalHistory.' + ) + content.extend(past_surgical_history) + if relevant_diagnostic_tests_and_or_laboratory_data is not None: + if not isinstance(relevant_diagnostic_tests_and_or_laboratory_data, + RelevantDiagnosticTestsAndOrLaboratoryData): + raise TypeError( + 'Argument ' + + '"relevant_diagnostic_tests_and_or_laboratory_data" ' + + 'must be a RelevantDiagnosticTestsAndOrLaboratoryData.' + ) + content.extend(relevant_diagnostic_tests_and_or_laboratory_data) + if history_of_medication_use is not None: + if not isinstance(history_of_medication_use, + HistoryOfMedicationUse): + raise TypeError( + 'Argument "history_of_medication_use" must be ' + + 'a HistoryOfMedicationUse.' + ) + content.extend(history_of_medication_use) + if history_of_family_member_diseases is not None: + if not isinstance(history_of_family_member_diseases, + HistoryOfFamilyMemberDiseases): + raise TypeError( + 'Argument "history_of_family_member_diseases" must ' + + 'be a HistoryOfFamilyMemberDiseases.' + ) + content.extend(history_of_family_member_diseases) + if history_of_medical_device_use is not None: + if not isinstance(history_of_medical_device_use, + HistoryOfMedicalDeviceUse): + raise TypeError( + 'Argument "history_of_medical_device_use" must be a ' + + 'HistoryOfMedicalDeviceUse.' + ) + content.extend(history_of_medical_device_use) + if len(content) > 0: + item.ContentSequence = content + super().__init__([item]) diff --git a/tests/test_tid2000.py b/tests/test_tid2000.py new file mode 100644 index 00000000..a4fc6512 --- /dev/null +++ b/tests/test_tid2000.py @@ -0,0 +1,349 @@ +import unittest + +from pydicom.sr.codedict import codes +from highdicom.sr import ( + LanguageOfContentItemAndDescendants, + PersonObserverIdentifyingAttributes +) +from highdicom.sr.coding import CodedConcept +from highdicom.sr.enum import RelationshipTypeValues +from highdicom.sr.value_types import Code, ImageContentItem +from highdicom.sr.templates import ( + BasicDiagnosticImagingReport, + DiagnosticImagingReportHeading, + EquivalentMeaningsOfConceptNameText, + EquivalentMeaningsOfConceptNameCode, + ReportNarrativeCode, + ReportNarrativeText, + LanguageOfValue, + ObservationContext, + ObserverContext +) + + +class TestEquivalentMeaningsOfConceptNameText(unittest.TestCase): + + def setUp(self): + super().setUp() + self.lang = LanguageOfValue( + language=Code('en-US', 'English-US', 'RFC5646') + ) + + def test_construction(self): + item = EquivalentMeaningsOfConceptNameText('alt term') + self.assertEqual(item.ValueType, 'TEXT') + self.assertEqual(item.RelationshipType, + RelationshipTypeValues.HAS_CONCEPT_MOD.value) + self.assertEqual( + item.ConceptNameCodeSequence[0], + codes.DCM.EquivalentMeaningOfConceptName) + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_language(self): + item = EquivalentMeaningsOfConceptNameText( + 'alt', language_of_value=self.lang) + self.assertTrue(hasattr(item, 'ContentSequence')) + self.assertIn(self.lang, item.ContentSequence) + + def test_from_dataset(self): + ds = EquivalentMeaningsOfConceptNameText('alt') + round_item = EquivalentMeaningsOfConceptNameText.from_dataset(ds) + self.assertIsInstance(round_item, EquivalentMeaningsOfConceptNameText) + + +class TestEquivalentMeaningsOfConceptNameCode(unittest.TestCase): + + def setUp(self): + super().setUp() + self.code_val = Code('99TST', 'Test', '99TEST') + self.concept_val = CodedConcept('100', 'Concept', '99TEST') + self.lang = LanguageOfValue( + language=Code('de-DE', 'German', 'RFC5646')) + + def test_accepts_code(self): + item = EquivalentMeaningsOfConceptNameCode(self.code_val) + self.assertEqual(item.ValueType, 'CODE') + self.assertEqual(item.ConceptCodeSequence[0], self.code_val) + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_accepts_coded_concept(self): + item = EquivalentMeaningsOfConceptNameCode( + self.concept_val, language_of_value=self.lang) + self.assertEqual(item.ConceptCodeSequence[0], self.concept_val) + self.assertIn(self.lang, item.ContentSequence) + + def test_rejects_str(self): + with self.assertRaises(TypeError): + EquivalentMeaningsOfConceptNameCode( + "string") + + def test_from_dataset(self): + item = EquivalentMeaningsOfConceptNameCode(self.code_val) + recovered = EquivalentMeaningsOfConceptNameCode.from_dataset(item) + self.assertIsInstance(recovered, EquivalentMeaningsOfConceptNameCode) + + +class TestReportNarrativeCode(unittest.TestCase): + + def setUp(self): + super().setUp() + self.code = CodedConcept('888', 'Narr', '99TEST') + self.empty_obs = [] + self.img = ImageContentItem( + name=codes.DCM.SourceImageForSegmentation, + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4', + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + + def test_construction(self): + item = ReportNarrativeCode( + self.code, + basic_diagnostic_imaging_report_observations=self.empty_obs) + self.assertEqual(item.ValueType, 'CODE') + self.assertEqual(item.ConceptCodeSequence[0], self.code) + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_observation(self): + item = ReportNarrativeCode( + self.code, + basic_diagnostic_imaging_report_observations=[self.img]) + self.assertIn(self.img, item.ContentSequence) + + def test_obs_not_iterable(self): + with self.assertRaises(TypeError): + ReportNarrativeCode( + self.code, basic_diagnostic_imaging_report_observations=42) + + def test_obs_wrong_item_type(self): + with self.assertRaises(TypeError): + ReportNarrativeCode( + self.code, + basic_diagnostic_imaging_report_observations=[None]) + + def test_from_dataset(self): + item = ReportNarrativeCode(self.code, self.empty_obs) + recovered = ReportNarrativeCode.from_dataset(item) + self.assertIsInstance(recovered, ReportNarrativeCode) + + +class TestReportNarrativeText(unittest.TestCase): + + def setUp(self): + super().setUp() + self.empty_obs = [] + self.img = ImageContentItem( + name=codes.DCM.SourceImageForSegmentation, + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4', + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + + def test_construction(self): + item = ReportNarrativeText( + 'free text', + basic_diagnostic_imaging_report_observations=self.empty_obs) + self.assertEqual(item.ValueType, 'TEXT') + self.assertEqual(item.ConceptNameCodeSequence[0].CodeValue, '7002') + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_observation(self): + item = ReportNarrativeText( + 'obs', + basic_diagnostic_imaging_report_observations=[self.img]) + self.assertIn(self.img, item.ContentSequence) + + def test_obs_not_iterable(self): + with self.assertRaises(TypeError): + ReportNarrativeText( + 'x', basic_diagnostic_imaging_report_observations=123) + + def test_obs_wrong_item_type(self): + with self.assertRaises(TypeError): + ReportNarrativeText( + 'x', + basic_diagnostic_imaging_report_observations=['bad']) + + def test_from_dataset(self): + item = ReportNarrativeText( + 'abc', + basic_diagnostic_imaging_report_observations=self.empty_obs) + recovered = ReportNarrativeText.from_dataset(item) + self.assertIsInstance(recovered, ReportNarrativeText) + + +class TestDiagnosticImagingReportHeading(unittest.TestCase): + + def setUp(self): + super().setUp() + self.narr_text = ReportNarrativeText('txt', []) + self.narr_code = ReportNarrativeCode(CodedConcept('1', 'n', '99'), []) + person = PersonObserverIdentifyingAttributes( + name='Doe^John' + ) + self.obs_ctx = ObservationContext( + observer_person_context=ObserverContext( + observer_type=codes.cid270.Person, + observer_identifying_attributes=person + ) + ) + + def test_construction_with_text_narrative(self): + head = DiagnosticImagingReportHeading(self.narr_text) + container = head[0] + self.assertEqual( + container.ContentTemplateSequence[0].TemplateIdentifier, '7001') + self.assertIn(self.narr_text, container.ContentSequence) + + def test_construction_with_code_narrative(self): + head = DiagnosticImagingReportHeading(self.narr_code) + self.assertIn(self.narr_code, head[0].ContentSequence) + + def test_construction_with_observation_context(self): + head = DiagnosticImagingReportHeading( + self.narr_text, observation_context=self.obs_ctx) + for item in self.obs_ctx: + self.assertIn(item, head[0].ContentSequence) + + def test_invalid_narrative_type(self): + with self.assertRaises(TypeError): + DiagnosticImagingReportHeading("bad") + + def test_invalid_observation_context(self): + with self.assertRaises(TypeError): + DiagnosticImagingReportHeading( + self.narr_text, observation_context="bad") + + +class TestBasicDiagnosticImagingReport(unittest.TestCase): + + def setUp(self): + super().setUp() + self.lang = LanguageOfContentItemAndDescendants( + language=Code('en-US', 'English (US)', 'RFC5646') + ) + person = PersonObserverIdentifyingAttributes( + name='Doe^John' + ) + self.obs_ctx = ObservationContext( + observer_person_context=ObserverContext( + observer_type=codes.cid270.Person, + observer_identifying_attributes=person + ) + ) + self.proc_code = CodedConcept('113724', 'CT Chest', 'SCT') + self.dev_code = Code('99DEV', 'CT scanner', '99TEST') + self.reg_code = Code('T-D3000', 'Lung', 'SRT') + + self.test_code_item = EquivalentMeaningsOfConceptNameCode( + value=Code('99ALT', 'Alt-code', '99TEST') + ) + self.test_text_item = EquivalentMeaningsOfConceptNameText( + value='Alternative term' + ) + self.heading = DiagnosticImagingReportHeading( + report_narrative=ReportNarrativeText( + value='Simple narrative', + basic_diagnostic_imaging_report_observations=[] + ) + ) + + def test_construction(self): + report = BasicDiagnosticImagingReport( + language_of_content_item_and_descendants=self.lang, + observation_context=self.obs_ctx, + ) + self.assertEqual(len(report), 1) + container = report[0] + self.assertEqual(container.ValueType, 'CONTAINER') + self.assertEqual( + container.ContentTemplateSequence[0].TemplateIdentifier, + '2000' + ) + self.assertIn(self.lang[0], container.ContentSequence) + self.assertTrue( + any(ci.RelationshipType == + RelationshipTypeValues.HAS_OBS_CONTEXT.value + for ci in container.ContentSequence), + 'ObservationContext items missing' + ) + + def test_construction_with_optionals(self): + report = BasicDiagnosticImagingReport( + language_of_content_item_and_descendants=self.lang, + observation_context=self.obs_ctx, + procedures_reported=[self.proc_code], + acquisition_device_types=[self.dev_code], + target_regions=[self.reg_code], + equivalent_meanings_of_concept_name=[ + self.test_text_item, self.test_code_item], + diagnostic_imaging_report_headings=[self.heading], + ) + container = report[0] + seq = container.ContentSequence + self.assertTrue( + any(item.ConceptNameCodeSequence[0] == codes.DCM.ProcedureReported + for item in seq), + '"Procedure Reported" items missing' + ) + self.assertTrue( + any(item.ConceptNameCodeSequence[0] == + codes.DCM.AcquisitionDeviceType + for item in seq), + '"Acquisition Device Type" items missing' + ) + self.assertTrue( + any(item.ConceptNameCodeSequence[0] == codes.DCM.TargetRegion + for item in seq), + '"Target Region" items missing' + ) + for equiv in (self.test_text_item, self.test_code_item): + self.assertIn(equiv, seq) + for child in self.heading: + self.assertIn(child, seq) + + def test_mandatory_args_typecheck(self): + with self.assertRaises(TypeError): + BasicDiagnosticImagingReport( + language_of_content_item_and_descendants='wrong type', + observation_context=self.obs_ctx, + ) + + with self.assertRaises(TypeError): + BasicDiagnosticImagingReport( + language_of_content_item_and_descendants=self.lang, + observation_context='wrong type', + ) + + def test_sequence_arg_must_be_iterable(self): + not_seq = 123 + for kw in ( + dict(procedures_reported=not_seq), + dict(acquisition_device_types=not_seq), + dict(target_regions=not_seq), + dict(equivalent_meanings_of_concept_name=not_seq), + dict(diagnostic_imaging_report_headings=not_seq), + ): + with self.assertRaises(TypeError): + BasicDiagnosticImagingReport( + language_of_content_item_and_descendants=self.lang, + observation_context=self.obs_ctx, + **kw + ) + + def test_sequence_item_type_validation(self): + wrong = 'string' + wrong_cases = ( + dict(procedures_reported=[wrong]), + dict(acquisition_device_types=[wrong]), + dict(target_regions=[wrong]), + dict(equivalent_meanings_of_concept_name=[wrong]), + dict(diagnostic_imaging_report_headings=[wrong]), + ) + for kw in wrong_cases: + with self.assertRaises(TypeError): + BasicDiagnosticImagingReport( + language_of_content_item_and_descendants=self.lang, + observation_context=self.obs_ctx, + **kw + ) diff --git a/tests/test_tid3700.py b/tests/test_tid3700.py new file mode 100644 index 00000000..085d2e21 --- /dev/null +++ b/tests/test_tid3700.py @@ -0,0 +1,846 @@ +import unittest +from datetime import datetime + +from highdicom.sr import PersonObserverIdentifyingAttributes +from highdicom.sr.value_types import ( + Code, + CodedConcept, + CompositeContentItem, + TemporalRangeTypeValues, + UIDRefContentItem, + WaveformContentItem, + TcoordContentItem, + RelationshipTypeValues, +) +from highdicom.sr.templates import ( + CardiovascularPatientHistory, + ECGFinding, + ECGGlobalMeasurements, + ECGLeadMeasurements, + ECGQualitativeAnalysis, + ECGReport, + ECGWaveFormInformation, + ECGMeasurementSource, + IndicationsForProcedure, + NumberOfEctopicBeats, + ObserverContext, + PatientCharacteristicsForECG, + PriorECGStudy, + QTcIntervalGlobal, + QuantitativeAnalysis, + SummaryECG, + LanguageOfContentItemAndDescendants, + AgeUnit, + PressureUnit +) +from pydicom.sr.codedict import codes + + +class TestECGWaveFormInformation(unittest.TestCase): + + def setUp(self): + super().setUp() + self.proc_dt = datetime.now() + self.wf_item = WaveformContentItem( + name=Code('123', 'Waveform', '99TEST'), + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4', + relationship_type=RelationshipTypeValues.CONTAINS + ) + self.lead_code = Code('123', 'Lead-X', '99TEST') + self.acq_dev = 'Recorder-X' + self.equip_id = 'SN-42' + self.room_id = ['Room-1'] + self.num_vars = [1.1, 2.2] + self.txt_vars = ['on', 'off'] + + def test_construction(self): + tpl = ECGWaveFormInformation(self.proc_dt) + cont = tpl[0] + print(cont) + self.assertEqual( + cont.ContentTemplateSequence[0].TemplateIdentifier, + '3708' + ) + self.assertEqual(cont.ContentSequence[0].ValueType, 'DATETIME') + + def test_construction_with_optionals(self): + tpl = ECGWaveFormInformation( + self.proc_dt, + source_of_measurement=self.wf_item, + lead_system=self.lead_code, + acquisition_device_type=self.acq_dev, + equipment_identification=self.equip_id, + room_identification=self.room_id, + ecg_control_numeric_variables=self.num_vars, + ecg_control_text_variables=self.txt_vars, + ) + seq = tpl[0].ContentSequence + self.assertIn(self.wf_item, seq) + self.assertTrue(any(ci.ValueType == 'NUM' for ci in seq)) + self.assertTrue(any(ci.ValueType == 'TEXT' and + ci.ConceptNameCodeSequence[0].CodeValue == '3691' + for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, source_of_measurement=123) + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, lead_system='bad') + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, acquisition_device_type=42) + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, equipment_identification=[]) + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, room_identification='oops') + with self.assertRaises(TypeError): + ECGWaveFormInformation( + self.proc_dt, ecg_control_numeric_variables='x') + with self.assertRaises(TypeError): + ECGWaveFormInformation( + self.proc_dt, ecg_control_numeric_variables=['x']) + with self.assertRaises(TypeError): + ECGWaveFormInformation(self.proc_dt, ecg_control_text_variables=0) + with self.assertRaises(TypeError): + ECGWaveFormInformation( + self.proc_dt, + ecg_control_text_variables=[1]) + + +class TestECGMeasurementSource(unittest.TestCase): + + def setUp(self): + super().setUp() + self.beat = '5' + self.method_code = CodedConcept('555', 'Auto', '99TEST') + self.tcoord_item = TcoordContentItem( + name=Code('123', 'Tcoord', '99TEST'), + temporal_range_type=TemporalRangeTypeValues.POINT, + referenced_date_time=[datetime.now()], + relationship_type=RelationshipTypeValues.CONTAINS + ) + + def test_construction(self): + tpl = ECGMeasurementSource(self.beat, None, None) + cont = tpl[0] + self.assertEqual( + cont.ContentTemplateSequence[0].TemplateIdentifier, + '3715' + ) + self.assertEqual(cont.ContentSequence[0].ValueType, 'TEXT') + self.assertEqual(cont.ContentSequence[0].TextValue, self.beat) + + def test_construction_with_optionals(self): + tpl = ECGMeasurementSource( + self.beat, self.method_code, self.tcoord_item) + seq = tpl[0].ContentSequence + self.assertIn(self.tcoord_item, seq) + self.assertTrue( + any(ci.ValueType == 'CODE' and + ci.ConceptNameCodeSequence[0] == codes.SCT.MeasurementMethod + for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ECGMeasurementSource(123, None, None) + with self.assertRaises(TypeError): + ECGMeasurementSource(self.beat, 'str', None) + with self.assertRaises(TypeError): + ECGMeasurementSource(self.beat, None, 42) + + +class TestQTcIntervalGlobal(unittest.TestCase): + + def setUp(self): + super().setUp() + self.alg_code = Code('ALG', 'Baz', '99TEST') + + def test_construction(self): + item = QTcIntervalGlobal(400.0) + self.assertEqual(item.ValueType, 'NUM') + unit = item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0] + self.assertEqual(unit, codes.UCUM.Millisecond) + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_algorithm(self): + item = QTcIntervalGlobal(420.5, algorithm_name=self.alg_code) + self.assertIn( + self.alg_code, item.ContentSequence[0].ConceptCodeSequence) + self.assertEqual(item.ContentSequence[0].RelationshipType, + RelationshipTypeValues.HAS_PROPERTIES.value) + + def test_type_guard(self): + with self.assertRaises(TypeError): + QTcIntervalGlobal(300.0, algorithm_name='bad') + + def test_from_dataset(self): + item = QTcIntervalGlobal(450.0) + rebuilt = QTcIntervalGlobal.from_dataset(item) + self.assertIsInstance(rebuilt, QTcIntervalGlobal) + + +class TestNumberOfEctopicBeats(unittest.TestCase): + + def setUp(self): + super().setUp() + self.morph_code = CodedConcept('999', 'PVC', '99TEST') + + def test_construction(self): + item = NumberOfEctopicBeats(7) + unit = item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0] + self.assertEqual(unit.CodeValue, 'beats') + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_morphologies(self): + item = NumberOfEctopicBeats( + 9, associated_morphologies=[self.morph_code]) + self.assertIn(self.morph_code, + item.ContentSequence[0].ConceptCodeSequence) + + def test_type_guards(self): + with self.assertRaises(TypeError): + NumberOfEctopicBeats(3, associated_morphologies=123) + with self.assertRaises(TypeError): + NumberOfEctopicBeats(3, associated_morphologies=['bad']) + + def test_from_dataset(self): + item = NumberOfEctopicBeats(2) + rebuilt = NumberOfEctopicBeats.from_dataset(item) + self.assertIsInstance(rebuilt, NumberOfEctopicBeats) + + +class TestECGGlobalMeasurements(unittest.TestCase): + + def setUp(self): + super().setUp() + self.base_vals = dict( + ventricular_heart_rate=60.0, + qt_interval_global=350.0, + pr_interval_global=160.0, + qrs_duration_global=90.0, + rr_interval_global=1000.0, + ) + self.msrc = ECGMeasurementSource( + '1', None, + TcoordContentItem( + name=Code('123', 'Tcoord', '99TEST'), + temporal_range_type=TemporalRangeTypeValues.POINT, + referenced_date_time=[datetime.now()], + relationship_type=RelationshipTypeValues.CONTAINS + )) + self.qtc = QTcIntervalGlobal(400.0) + self.durations = [120.0, 130.0] + self.axes = [45.0, -30.0] + self.all_beats = 700.0 + self.ectopic_num = NumberOfEctopicBeats( + value=5.0 + ) + + def test_construction(self): + tpl = ECGGlobalMeasurements(**self.base_vals) + cont = tpl[0] + self.assertEqual( + cont.ContentTemplateSequence[0].TemplateIdentifier, + '3713' + ) + self.assertEqual( + cont.ConceptNameCodeSequence[0], codes.DCM.ECGGlobalMeasurements) + self.assertEqual( + sum(1 for ci in cont.ContentSequence if ci.ValueType == 'NUM'), + 5 + ) + bpm_unit = cont.ContentSequence[0]\ + .MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0] + self.assertEqual(bpm_unit, Code('bpm', 'UCUM', 'beats per minute')) + + def test_construction_with_optionals(self): + tpl = ECGGlobalMeasurements( + **self.base_vals, + ecg_measurement_source=self.msrc, + atrial_heart_rate=75.0, + qtc_interval_global=self.qtc, + ecg_global_waveform_durations=self.durations, + ecg_axis_measurements=self.axes, + count_of_all_beats=self.all_beats, + number_of_ectopic_beats=self.ectopic_num, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any(item in seq for item in self.msrc)) + self.assertIn(self.qtc, seq) + self.assertTrue(any(ci.ContentSequence[0].TextValue == '1' if + getattr(ci, "MeasuredValueSequence", None) is + None else getattr(ci, + "MeasuredValueSequence" + )[0].NumericValue == + 75.0 for ci in seq)) + self.assertEqual( + sum( + 1 for ci in seq + if ci.ConceptNameCodeSequence[0].CodeValue == '3687'), + len(self.durations) + ) + self.assertEqual( + sum( + 1 for ci in seq + if ci.ConceptNameCodeSequence[0].CodeValue == '3229'), + len(self.axes) + ) + self.assertTrue( + any(ci.MeasuredValueSequence[0].NumericValue == self.all_beats if + getattr(ci, "MeasuredValueSequence", None) is not None + else False for ci in seq)) + self.assertTrue( + any(ci.MeasuredValueSequence[0].NumericValue == + self.ectopic_num.value if getattr(ci, + "MeasuredValueSequence", + None) is + not None else False + for ci in seq)) + + def test_type_guard_mandatory(self): + bad = self.base_vals.copy() + bad['ventricular_heart_rate'] = 'fast' + with self.assertRaises(TypeError): + ECGGlobalMeasurements(**bad) + + def test_bad_measurement_source(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements( + **self.base_vals, ecg_measurement_source='bad') + + def test_bad_atrial_rate(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements(**self.base_vals, atrial_heart_rate='x') + + def test_bad_qtc_interval(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements(**self.base_vals, qtc_interval_global=123) + + def test_bad_duration_sequence(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements( + **self.base_vals, ecg_global_waveform_durations=123) + with self.assertRaises(TypeError): + ECGGlobalMeasurements( + **self.base_vals, ecg_global_waveform_durations=['x']) + + def test_bad_axis_sequence(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements(**self.base_vals, ecg_axis_measurements='x') + with self.assertRaises(TypeError): + ECGGlobalMeasurements( + **self.base_vals, ecg_axis_measurements=[None]) + + def test_bad_all_beats(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements(**self.base_vals, count_of_all_beats='many') + + def test_bad_ectopic_beats(self): + with self.assertRaises(TypeError): + ECGGlobalMeasurements( + **self.base_vals, number_of_ectopic_beats='PVC') + + +class TestECGLeadMeasurements(unittest.TestCase): + + def setUp(self): + super().setUp() + self.lead_code = Code('lead1', 'Lead I', '99') + # Create a dummy ECGMeasurementSource that is iterable. + self.ecg_source = ECGMeasurementSource("5", None, None) + self.durations = [100.0, 200.5] + self.voltages = [0.5, 1.2] + self.st_finding = Code('st1', 'ST abnormality', '99') + self.findings = [Code('st1', 'ST abnormality', '99'), + Code('st2', 'ST abnormality', '99')] + + def test_construction(self): + meas = ECGLeadMeasurements(self.lead_code) + container = meas[0] + seq = container.ContentSequence + self.assertEqual(len(seq), 1) + lead_item = seq[0] + self.assertEqual(lead_item.value, self.lead_code) + + def test_invalid_lead_id(self): + with self.assertRaises(TypeError): + ECGLeadMeasurements("bad_lead") + + def test_ecg_measurement_source(self): + meas = ECGLeadMeasurements( + self.lead_code, ecg_measurement_source=self.ecg_source) + container = meas[0] + seq = container.ContentSequence + dummy_item = next(iter(self.ecg_source)) + self.assertIn(dummy_item, seq) + + def test_valid_durations(self): + meas = ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_durations=self.durations) + seq = meas[0].ContentSequence + self.assertEqual(len(seq), 1 + len(self.durations)) + dur_item = seq[1] + self.assertEqual(dur_item.value, self.durations[0]) + self.assertEqual(dur_item.unit, codes.UCUM.Millisecond) + + def test_invalid_durations(self): + with self.assertRaises(TypeError): + ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_durations=123) + with self.assertRaises(TypeError): + ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_durations=["bad"]) + + def test_valid_voltages(self): + meas = ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_voltages=self.voltages) + seq = meas[0].ContentSequence + self.assertEqual(len(seq), 1 + len(self.voltages)) + voltage_item = seq[1] + self.assertEqual(voltage_item.value, self.voltages[0]) + self.assertEqual(voltage_item.unit, codes.UCUM.Millivolt) + + def test_invalid_voltages(self): + with self.assertRaises(TypeError): + ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_voltages="bad") + with self.assertRaises(TypeError): + ECGLeadMeasurements( + self.lead_code, electrophysiology_waveform_voltages=[None]) + + def test_valid_st_segment_finding(self): + meas = ECGLeadMeasurements( + self.lead_code, st_segment_finding=self.st_finding) + seq = meas[0].ContentSequence + self.assertEqual(len(seq), 2) + st_item = seq[1] + self.assertEqual(st_item.value, self.st_finding) + + def test_invalid_st_segment_finding(self): + with self.assertRaises(TypeError): + ECGLeadMeasurements(self.lead_code, st_segment_finding=123) + + def test_valid_findings(self): + meas = ECGLeadMeasurements(self.lead_code, findings=self.findings) + seq = meas[0].ContentSequence + self.assertEqual(len(seq), 1 + len(self.findings)) + finding_item = seq[1] + self.assertEqual(finding_item.value, self.findings[0]) + + def test_invalid_findings(self): + with self.assertRaises(TypeError): + ECGLeadMeasurements(self.lead_code, findings="bad") + with self.assertRaises(TypeError): + ECGLeadMeasurements(self.lead_code, findings=["bad"]) + + def test_construction_all_optionals(self): + meas = ECGLeadMeasurements( + self.lead_code, + ecg_measurement_source=self.ecg_source, + electrophysiology_waveform_durations=self.durations, + electrophysiology_waveform_voltages=self.voltages, + st_segment_finding=self.st_finding, + findings=self.findings, + ) + seq = meas[0].ContentSequence + expected_count = (1 + len(self.durations) + len(self.voltages) + + 1 + len(self.findings) + len(list(self.ecg_source))) + self.assertEqual(len(seq), expected_count) + + +class TestQuantitativeAnalysis(unittest.TestCase): + + def setUp(self): + super().setUp() + self.global_meas = ECGGlobalMeasurements( + ventricular_heart_rate=60.0, + qt_interval_global=350.0, + pr_interval_global=160.0, + qrs_duration_global=90.0, + rr_interval_global=1000.0, + ) + self.lead_meas = ECGLeadMeasurements(Code('lead1', 'Lead I', '99')) + + def test_construction(self): + tpl = QuantitativeAnalysis() + self.assertEqual(len(tpl[0].ContentSequence), 0) + + def test_construction_with_global(self): + tpl = QuantitativeAnalysis(ecg_global_measurements=self.global_meas) + for item in self.global_meas: + self.assertIn(item, tpl[0].ContentSequence) + + def test_construction_with_leads(self): + tpl = QuantitativeAnalysis(ecg_lead_measurements=[self.lead_meas]) + self.assertIn(self.lead_meas[0], tpl[0].ContentSequence) + + def test_construction_with_optionals(self): + tpl = QuantitativeAnalysis( + ecg_global_measurements=self.global_meas, + ecg_lead_measurements=[self.lead_meas], + ) + seq = tpl[0].ContentSequence + self.assertTrue(any(item in seq for item in self.global_meas)) + self.assertIn(self.lead_meas[0], seq) + + def test_type_guards(self): + with self.assertRaises(TypeError): + QuantitativeAnalysis(ecg_global_measurements='bad') + with self.assertRaises(TypeError): + QuantitativeAnalysis(ecg_lead_measurements='bad') + with self.assertRaises(TypeError): + QuantitativeAnalysis(ecg_lead_measurements=[123]) + + +class TestIndicationsForProcedure(unittest.TestCase): + + def setUp(self): + super().setUp() + self.code1 = Code('A', 'Alpha', '99') + self.concept = CodedConcept('B', 'Bravo', '99') + self.text = 'Chest pain' + + def test_construction(self): + tpl = IndicationsForProcedure() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_construction_with_codes(self): + tpl = IndicationsForProcedure(findings=[self.code1, self.concept]) + seq = tpl[0].ContentSequence + self.assertEqual(len(seq), 2) + self.assertTrue(all(ci.ValueType == 'CODE' for ci in seq)) + + def test_construction_with_text(self): + tpl = IndicationsForProcedure(finding_text=self.text) + itm = tpl[0].ContentSequence[0] + self.assertEqual(itm.TextValue, self.text) + + def test_construction_with_optionals(self): + tpl = IndicationsForProcedure( + findings=[self.code1], finding_text=self.text) + seq = tpl[0].ContentSequence + self.assertEqual(len(seq), 2) + + def test_type_guards(self): + with self.assertRaises(TypeError): + IndicationsForProcedure(findings='bad') + with self.assertRaises(TypeError): + IndicationsForProcedure(findings=[42]) + with self.assertRaises(TypeError): + IndicationsForProcedure(finding_text=123) + + +class TestPatientCharacteristicsForECG(unittest.TestCase): + + def setUp(self): + super().setUp() + self.age = AgeUnit(2000, 1, 1) + self.sys_bp = PressureUnit(1, 1) + self.dia_bp = PressureUnit(2, 2) + self.state = Code('REST', 'Resting', '99') + self.pace = CodedConcept('P', 'Pacemaker', '99') + self.icd = CodedConcept('ICD', 'ICD in situ', '99') + + def test_construction(self): + tpl = PatientCharacteristicsForECG(self.age, 'M') + cont = tpl[0] + self.assertEqual( + cont.ContentTemplateSequence[0].TemplateIdentifier, + '3704' + ) + self.assertTrue( + any(ci.ValueType == 'TEXT' and + ci.ConceptNameCodeSequence[0] == codes.DCM.SubjectSex + for ci in cont.ContentSequence)) + + def test_construction_with_optionals(self): + tpl = PatientCharacteristicsForECG( + self.age, 'F', + patient_height=180.0, + patient_weight=80.0, + systolic_blood_pressure=self.sys_bp, + diastolic_blood_pressure=self.dia_bp, + patient_state=self.state, + pacemaker_in_situ=self.pace, + icd_in_situ=self.icd, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any( + ci.ValueType == 'NUM' and + ci.ConceptNameCodeSequence[0].CodeValue == '8302-2' + for ci in seq)) + self.assertTrue(any( + ci.ValueType == 'NUM' and + ci.ConceptNameCodeSequence[0].CodeValue == '29463-7' + for ci in seq)) + self.assertTrue(any( + ci.ValueType == 'CODE' and + ci.ConceptNameCodeSequence[0] == codes.DCM.PatientState + for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + PatientCharacteristicsForECG('age', 'M') + with self.assertRaises(TypeError): + PatientCharacteristicsForECG(self.age, 1) + with self.assertRaises(TypeError): + PatientCharacteristicsForECG( + self.age, 'M', patient_height='tall') + with self.assertRaises(TypeError): + PatientCharacteristicsForECG( + self.age, 'M', patient_weight='heavy') + with self.assertRaises(TypeError): + PatientCharacteristicsForECG( + self.age, 'M', systolic_blood_pressure='bad') + with self.assertRaises(TypeError): + PatientCharacteristicsForECG( + self.age, 'M', patient_state='bad') + + +class TestPriorECGStudy(unittest.TestCase): + + def setUp(self): + super().setUp() + self.base_code = Code('Y', 'Yes', '99') + self.uid_item = UIDRefContentItem( + name=codes.DCM.SeriesInstanceUID, + value='1.2.3.4.5.6', + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + self.rep_item = CompositeContentItem( + name=codes.DCM.SeriesInstanceUID, + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4', + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + self.wf_item = WaveformContentItem( + name=Code('123', 'Waveform', '99TEST'), + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4', + relationship_type=RelationshipTypeValues.CONTAINS + ) + self.dt = datetime.now() + + def test_construction(self): + tpl = PriorECGStudy(self.base_code) + self.assertEqual(tpl[0].ConceptNameCodeSequence[0], + codes.LN.PriorProcedureDescriptions) + self.assertTrue( + any(ci.ValueType == 'CODE' for ci in tpl[0].ContentSequence)) + + def test_construction_with_optionals(self): + tpl = PriorECGStudy( + self.base_code, + procedure_datetime=self.dt, + procedure_study_instance_uid=self.uid_item, + prior_report_for_current_patient=self.rep_item, + source_of_measurement=self.wf_item, + ) + seq = tpl[0].ContentSequence + self.assertIn(self.uid_item, seq) + self.assertIn(self.rep_item, seq) + self.assertIn(self.wf_item, seq) + self.assertTrue(any(ci.ValueType == 'DATETIME' for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + PriorECGStudy('bad') + with self.assertRaises(TypeError): + PriorECGStudy(self.base_code, procedure_datetime=123) + with self.assertRaises(TypeError): + PriorECGStudy(self.base_code, procedure_study_instance_uid='bad') + with self.assertRaises(TypeError): + PriorECGStudy(self.base_code, + prior_report_for_current_patient='bad') + with self.assertRaises(TypeError): + PriorECGStudy(self.base_code, source_of_measurement='bad') + + +class TestECGFinding(unittest.TestCase): + + def setUp(self): + super().setUp() + self.val_code = CodedConcept('Z', 'Finding', '99') + self.eq_text = 'equivalent' + self.sub_finding = ECGFinding(self.val_code) + + def test_construction(self): + itm = ECGFinding(self.val_code)[0] + print(itm) + self.assertEqual(itm.ValueType, 'CODE') + self.assertEqual(itm.ConceptCodeSequence[0], self.val_code) + + def test_construction_with_equivalent_and_nested(self): + itm = ECGFinding( + self.val_code, + equivalent_meaning_of_value=self.eq_text, ecg_findings=[ + self.sub_finding])[0] + seq = itm.ContentSequence + self.assertTrue( + any(ci.ValueType == 'TEXT' and ci.TextValue == self.eq_text + for ci in seq)) + self.assertIn(self.sub_finding[0], seq) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ECGFinding('bad') + with self.assertRaises(TypeError): + + ECGFinding(self.val_code, equivalent_meaning_of_value=123) + with self.assertRaises(TypeError): + ECGFinding(self.val_code, ecg_findings='bad') + with self.assertRaises(TypeError): + ECGFinding(self.val_code, ecg_findings=[123]) + + +class TestECGQualitativeAnalysis(unittest.TestCase): + + def setUp(self): + super().setUp() + self.text = 'Normal ECG' + self.finding_code = ECGFinding(CodedConcept('X', 'Some', '99')) + + def test_text_only(self): + tpl = ECGQualitativeAnalysis(ecg_finding_text=self.text) + self.assertTrue(any(ci.ValueType == 'TEXT' and ci.TextValue == + self.text for ci in tpl[0].ContentSequence)) + + def test_codes_only(self): + tpl = ECGQualitativeAnalysis(ecg_finding_codes=[self.finding_code]) + self.assertIn(self.finding_code[0], tpl[0].ContentSequence) + + def test_both(self): + tpl = ECGQualitativeAnalysis( + ecg_finding_text=self.text, ecg_finding_codes=[self.finding_code]) + seq = tpl[0].ContentSequence + self.assertTrue(any(ci.ValueType == 'TEXT' for ci in seq) and + self.finding_code[0] in seq) + + def test_required_argument(self): + with self.assertRaises(ValueError): + ECGQualitativeAnalysis() + + def test_type_guards(self): + with self.assertRaises(TypeError): + ECGQualitativeAnalysis(ecg_finding_text=123) + with self.assertRaises(TypeError): + ECGQualitativeAnalysis(ecg_finding_codes='bad') + with self.assertRaises(TypeError): + ECGQualitativeAnalysis(ecg_finding_codes=[123]) + + +class TestSummaryECG(unittest.TestCase): + + def setUp(self): + super().setUp() + self.sum_text = 'ECG appears normal' + # matches constructor expectation (str) + self.overall_code = CodedConcept('X', 'Finding', '99') + + def test_text_only(self): + tpl = SummaryECG(summary=self.sum_text) + self.assertTrue(any(ci.ValueType == 'TEXT' and ci.TextValue == + self.sum_text for ci in tpl[0].ContentSequence)) + + def test_code_only(self): + tpl = SummaryECG(ecg_overall_finding=self.overall_code) + self.assertTrue( + any(ci.ValueType == 'CODE' for ci in tpl[0].ContentSequence)) + + def test_both(self): + tpl = SummaryECG(summary=self.sum_text, + ecg_overall_finding=self.overall_code) + self.assertEqual(len(tpl[0].ContentSequence), 2) + + def test_type_guards(self): + with self.assertRaises(TypeError): + SummaryECG(summary=123) + with self.assertRaises(TypeError): + SummaryECG(ecg_overall_finding="Overall") + + +class TestECGReport(unittest.TestCase): + + def setUp(self): + super().setUp() + self.lang = LanguageOfContentItemAndDescendants( + language=Code('en-US', 'US-English', 'RFC5646') + ) + person = PersonObserverIdentifyingAttributes( + name='Doe^John' + ) + self.obs_ctx = ObserverContext( + observer_type=codes.cid270.Person, + observer_identifying_attributes=person + ) + self.wf_info = ECGWaveFormInformation(datetime.now()) + self.quant = QuantitativeAnalysis( + ecg_global_measurements=ECGGlobalMeasurements( + ventricular_heart_rate=60.0, + qt_interval_global=350.0, + pr_interval_global=160.0, + qrs_duration_global=90.0, + rr_interval_global=1000.0, + ) + ) + self.procedure_code = Code('PROC', 'Rest ECG', '99') + self.indication = IndicationsForProcedure() + self.cardio_hist = CardiovascularPatientHistory() + self.patient_chars = PatientCharacteristicsForECG( + AgeUnit(2000, 1, 1), "F" + ) + self.prior = PriorECGStudy( + Code('X', 'Finding', '99') + ) + self.qual = ECGQualitativeAnalysis(ecg_finding_text="Finding") + self.summary = SummaryECG() + + def test_construction(self): + rep = ECGReport( + self.lang, + [self.obs_ctx], + self.wf_info, + self.quant, + ) + cont = rep[0] + self.assertEqual( + cont.ContentTemplateSequence[0].TemplateIdentifier, + '3700' + ) + self.assertIn(self.wf_info[0], cont.ContentSequence) + + def test_construction_with_optionals(self): + rep = ECGReport( + self.lang, + [self.obs_ctx], + self.wf_info, + self.quant, + procedure_reported=self.procedure_code, + indications_for_procedure=self.indication, + cardiovascular_patient_history=self.cardio_hist, + patient_characteristics_for_ecg=self.patient_chars, + prior_ecg_study=self.prior, + ecg_qualitative_analysis=self.qual, + summary_ecg=self.summary, + ) + seq = rep[0].ContentSequence + self.assertTrue( + any(ci.ConceptNameCodeSequence[0] == codes.DCM.ProcedureReported + for ci in seq)) + + def test_guard_mandatory(self): + with self.assertRaises(TypeError): + ECGReport('bad', [self.obs_ctx], self.wf_info, + self.quant) + with self.assertRaises(TypeError): + ECGReport(self.lang, 'bad', self.wf_info, + self.quant) + with self.assertRaises(TypeError): + ECGReport(self.lang, [123], self.wf_info, + self.quant) + with self.assertRaises(TypeError): + ECGReport(self.lang, [self.obs_ctx], 'bad', + self.quant) + with self.assertRaises(TypeError): + ECGReport(self.lang, [self.obs_ctx], + self.wf_info, 'bad') diff --git a/tests/test_tid3802.py b/tests/test_tid3802.py new file mode 100644 index 00000000..4dedd282 --- /dev/null +++ b/tests/test_tid3802.py @@ -0,0 +1,618 @@ +import unittest +from datetime import datetime + +from pydicom.sr.codedict import codes + +from highdicom.sr.enum import RelationshipTypeValues +from highdicom.sr.templates import ( + CardiovascularPatientHistory, + FamilyHistoryOfClinicalFinding, + HistoryOfFamilyMemberDiseases, + HistoryOfMedicalDeviceUse, + HistoryOfMedicationUse, + MedicalDeviceUse, + MedicationTypeCode, + MedicationTypeText, + PastSurgicalHistory, + ProblemList, + ProblemProperties, + ProcedureProperties, + RelevantDiagnosticTestsAndOrLaboratoryData, + SocialHistory, + Therapy +) +from highdicom.sr.value_types import ( + Code, + CodedConcept, + CompositeContentItem +) + + +class TestTherapy(unittest.TestCase): + + def setUp(self): + super().setUp() + self.name_code = CodedConcept('T', 'Beta-blocker', '99') + self.status = Code('active', 'Active', '99') + + def test_construction(self): + item = Therapy(self.name_code) + self.assertEqual(item.ValueType, 'CODE') + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_status(self): + item = Therapy(self.name_code, status=self.status) + self.assertTrue(any( + ci.ConceptNameCodeSequence[0].CodeValue == '33999-4' + for ci in item.ContentSequence)) + + def test_type_guard(self): + with self.assertRaises(TypeError): + Therapy('bad') + + def test_from_dataset(self): + item = Therapy(self.name_code) + rebuilt = Therapy.from_dataset(item) + self.assertIsInstance(rebuilt, Therapy) + + +class TestProblemProperties(unittest.TestCase): + + def setUp(self): + super().setUp() + self.concern = Code('C', 'Concern', '99') + self.health = Code('healthy', 'Healthy', '99') + self.therapy = Therapy(CodedConcept('Drug', 'Statin', '99')) + self.note = 'Patient recovering well' + self.dt1 = datetime.now() + self.dt2 = datetime.now() + + def test_construction(self): + tpl = ProblemProperties(self.concern) + self.assertIn( + self.concern, tpl[0].ContentSequence[0].ConceptCodeSequence) + + def test_construction_with_optionals(self): + tpl = ProblemProperties( + self.concern, + datetime_concern_noted=self.dt1, + datetime_concern_resolved=self.dt2, + health_status=self.health, + therapies=[self.therapy], + comment=self.note, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any(ci.ValueType == 'DATETIME' for ci in seq)) + self.assertIn(self.therapy, seq) + self.assertTrue( + any(ci.ValueType == 'TEXT' and ci.TextValue == self.note + for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ProblemProperties('bad') + with self.assertRaises(TypeError): + ProblemProperties(self.concern, therapies='bad') + with self.assertRaises(TypeError): + ProblemProperties(self.concern, therapies=[123]) + + +class TestProblemList(unittest.TestCase): + + def setUp(self): + super().setUp() + self.concern_strings = ['Chest pain', 'Hypertension'] + self.pp_a = ProblemProperties(Code('C', 'Concern', '99')) + self.pp_b = ProblemProperties(Code('C', 'Concern', '99')) + + def test_construction(self): + tpl = ProblemList() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_concern_types_only(self): + tpl = ProblemList(concern_types=self.concern_strings) + seq = tpl[0].ContentSequence + self.assertEqual(len(seq), len(self.concern_strings)) + self.assertTrue(all(ci.ValueType == 'TEXT' for ci in seq)) + + def test_problem_properties(self): + tpl = ProblemList( + cardiac_patient_risk_factors=[self.pp_a], + history_of_diabetes_mellitus=self.pp_b, + ) + seq = tpl[0].ContentSequence + self.assertIn(self.pp_a[0], seq) + self.assertIn(self.pp_b[0], seq) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ProblemList(concern_types='bad') + with self.assertRaises(TypeError): + ProblemList(concern_types=[123]) + with self.assertRaises(TypeError): + ProblemList(cardiac_patient_risk_factors='bad') + with self.assertRaises(TypeError): + ProblemList(cardiac_patient_risk_factors=[123]) + with self.assertRaises(TypeError): + ProblemList(history_of_diabetes_mellitus='bad') + + +class TestSocialHistory(unittest.TestCase): + + def setUp(self): + super().setUp() + self.single_text = 'Lives alone' + self.multi_texts = ['Never smoker', 'Works night shifts'] + self.smoke_code = Code('smk', 'Smoker', '99') + self.drug_code = CodedConcept('drug', 'Drug misuse', '99') + + def test_construction(self): + tpl = SocialHistory() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_single_text(self): + tpl = SocialHistory(social_history=self.single_text) + itm = tpl[0].ContentSequence[0] + self.assertEqual(itm.TextValue, self.single_text) + + def test_multiple_texts(self): + tpl = SocialHistory(social_histories=self.multi_texts) + seq = tpl[0].ContentSequence + self.assertEqual(len(seq), len(self.multi_texts)) + + def test_codes(self): + tpl = SocialHistory( + tobacco_smoking_behavior=self.smoke_code, + drug_misuse_behavior=self.drug_code, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any( + ci.ConceptNameCodeSequence[0] == codes.SCT.TobaccoSmokingBehavior + for ci in seq)) + self.assertTrue( + any(ci.ConceptNameCodeSequence[0] == codes.SCT.DrugMisuseBehavior + for ci in seq)) + + def test_construction_with_optionals(self): + tpl = SocialHistory( + social_history=self.single_text, + social_histories=self.multi_texts, + tobacco_smoking_behavior=self.smoke_code, + drug_misuse_behavior=self.drug_code, + ) + seq = tpl[0].ContentSequence + self.assertGreaterEqual(len(seq), 4) + + def test_type_guards(self): + with self.assertRaises(TypeError): + SocialHistory(social_histories='bad') + with self.assertRaises(TypeError): + SocialHistory(social_histories=[123]) + + +class TestProcedureProperties(unittest.TestCase): + + def setUp(self): + super().setUp() + self.name = CodedConcept('ABC', 'Angioplasty', '99') + self.value = Code('done', 'Done', '99') + self.dt = datetime.now() + self.rep_item = CompositeContentItem( + Code('report', 'reported', '99'), + referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2', + referenced_sop_instance_uid='1.2.3.4.5.6.7.8.9.10', + relationship_type=RelationshipTypeValues.HAS_PROPERTIES + ) + self.perf_person = 'Doe^John' + self.perf_org = 'Charité Berlin' + self.comment = 'Procedure uneventful' + self.result_code = CodedConcept('R', 'Normal', '99') + + def test_construction(self): + item = ProcedureProperties(self.name, self.value)[0] + self.assertEqual(item.ConceptNameCodeSequence[0], self.name) + self.assertEqual(item.ConceptCodeSequence[0], self.value) + self.assertFalse(hasattr(item, 'ContentSequence')) + + def test_construction_with_optionals(self): + item = ProcedureProperties( + self.name, + self.value, + procedure_datetime=self.dt, + clinical_reports=[self.rep_item], + clinical_reports_text=['Rep A', 'Rep B'], + service_delivery_location='Ward 12', + service_performer_person=self.perf_person, + service_performer_organisation=self.perf_org, + comment=self.comment, + procedure_results=[self.result_code], + )[0] + seq = item.ContentSequence + self.assertTrue(any(ci.ValueType == 'DATETIME' for ci in seq)) + self.assertIn(self.rep_item, seq) + self.assertTrue(any(ci.TextValue == 'Rep A' for ci in seq + if getattr(ci, "TextValue", None) is not None)) + self.assertTrue(any(ci.TextValue == self.perf_org for ci in seq + if getattr(ci, "TextValue", None) is not None)) + self.assertTrue(any(ci.TextValue == self.comment for ci in seq + if getattr(ci, "TextValue", None) is not None)) + self.assertTrue( + any(ci.ConceptCodeSequence[0] == self.result_code for ci in seq + if getattr(ci, "ConceptCodeSequence", None) is not None)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + ProcedureProperties('bad', self.value) + with self.assertRaises(TypeError): + ProcedureProperties(self.name, 'bad') + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, clinical_reports='x') + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, clinical_reports=[1]) + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, + clinical_reports_text=123) + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, + clinical_reports_text=[1]) + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, procedure_results='x') + with self.assertRaises(TypeError): + ProcedureProperties(self.name, self.value, procedure_results=[123]) + + +class TestPastSurgicalHistory(unittest.TestCase): + + def setUp(self): + super().setUp() + self.hist_strings = ['Appendectomy', 'CABG'] + self.pp = ProcedureProperties( + CodedConcept('ABC', 'Angioplasty', '99'), + Code('done', 'Done', '99') + ) + + def test_construction(self): + tpl = PastSurgicalHistory() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_only_histories(self): + tpl = PastSurgicalHistory(histories=self.hist_strings) + self.assertEqual(len(tpl[0].ContentSequence), len(self.hist_strings)) + + def test_only_properties(self): + tpl = PastSurgicalHistory(procedure_properties=[self.pp]) + self.assertIn(self.pp[0], tpl[0].ContentSequence) + + def test_construction_with_optionals(self): + tpl = PastSurgicalHistory( + histories=self.hist_strings, procedure_properties=[self.pp]) + seq = tpl[0].ContentSequence + self.assertGreaterEqual(len(seq), len(self.hist_strings) + 1) + + def test_type_guards(self): + with self.assertRaises(TypeError): + PastSurgicalHistory(histories='bad') + with self.assertRaises(TypeError): + PastSurgicalHistory(histories=[123]) + with self.assertRaises(TypeError): + PastSurgicalHistory(procedure_properties='bad') + with self.assertRaises(TypeError): + PastSurgicalHistory(procedure_properties=[123]) + + +class TestRelevantDiagnosticTestsAndOrLaboratoryData(unittest.TestCase): + + def setUp(self): + super().setUp() + self.histories = ['Blood test 2023-06-12'] + self.pp = ProcedureProperties( + CodedConcept('ABC', 'Angioplasty', '99'), + Code('done', 'Done', '99') + ) + self.hdl = 55.0 + self.ldl = 130.0 + + def test_construction(self): + tpl = RelevantDiagnosticTestsAndOrLaboratoryData() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_construction_with_optionals(self): + tpl = RelevantDiagnosticTestsAndOrLaboratoryData( + histories=self.histories, + procedure_properties=[self.pp], + cholesterol_in_HDL=self.hdl, + cholesterol_in_LDL=self.ldl, + ) + seq = tpl[0].ContentSequence + self.assertEqual(sum(ci.ValueType == 'TEXT' for ci in seq), 1) + self.assertIn(self.pp[0], seq) + self.assertTrue(any(ci.ValueType == 'NUM' and + ci.ConceptNameCodeSequence[0].CodeValue == '2086-7' + for ci in seq)) + self.assertTrue(any(ci.ValueType == 'NUM' and + ci.ConceptNameCodeSequence[0].CodeValue == '2089-1' + for ci in seq)) + + def test_type_guards(self): + with self.assertRaises(TypeError): + RelevantDiagnosticTestsAndOrLaboratoryData(histories='bad') + with self.assertRaises(TypeError): + RelevantDiagnosticTestsAndOrLaboratoryData(histories=[123]) + with self.assertRaises(TypeError): + RelevantDiagnosticTestsAndOrLaboratoryData(procedure_properties='x') + with self.assertRaises(TypeError): + RelevantDiagnosticTestsAndOrLaboratoryData(procedure_properties=[1]) + + +class TestMedicationTypeText(unittest.TestCase): + + def setUp(self): + super().setUp() + self.status = Code('active', 'Active', '99') + + def test_construction(self): + itm = MedicationTypeText('Aspirin', self.status) + self.assertEqual(itm.ValueType, 'TEXT') + self.assertTrue(any( + ci.ConceptNameCodeSequence[0].CodeValue == '33999-4' + for ci in itm.ContentSequence)) + + def test_from_dataset(self): + itm = MedicationTypeText('Ibuprofen', self.status) + rebuilt = MedicationTypeText.from_dataset(itm) + self.assertIsInstance(rebuilt, MedicationTypeText) + + +class TestMedicationTypeCode(unittest.TestCase): + + def setUp(self): + super().setUp() + self.code_val = CodedConcept('RX', 'Metformin', '99') + self.status = Code('stopped', 'Stopped', '99') + + def test_construction(self): + itm = MedicationTypeCode( + self.code_val, + dosage=None, + status=self.status + ) + self.assertEqual(itm.ValueType, 'CODE') + self.assertFalse( + any(ci.ValueType == 'NUM' + for ci in getattr(itm, 'ContentSequence', []))) + + def test_with_dosage(self): + itm = MedicationTypeCode( + self.code_val, dosage=500.0, status=self.status) + self.assertTrue( + any(ci.ValueType == 'NUM' for ci in itm.ContentSequence)) + + def test_from_dataset(self): + itm = MedicationTypeCode( + self.code_val, dosage=250.0, status=self.status) + rebuilt = MedicationTypeCode.from_dataset(itm) + self.assertIsInstance(rebuilt, MedicationTypeCode) + + +class TestHistoryOfMedicationUse(unittest.TestCase): + + def setUp(self): + super().setUp() + self.txt_item = MedicationTypeText( + 'Aspirin', Code('active', 'Active', '99')) + self.code_item = MedicationTypeCode( + CodedConcept('RX', 'Metformin', '99'), + dosage=500.0, + status=Code('active', 'Active', '99')) + + def test_text_only(self): + tpl = HistoryOfMedicationUse(medication_types_text=[self.txt_item]) + self.assertIn(self.txt_item, tpl[0].ContentSequence) + + def test_code_only(self): + tpl = HistoryOfMedicationUse(medication_types_code=[self.code_item]) + self.assertIn(self.code_item, tpl[0].ContentSequence) + + def test_construction_with_optionals(self): + tpl = HistoryOfMedicationUse( + medication_types_text=[self.txt_item], + medication_types_code=[self.code_item], + ) + seq = tpl[0].ContentSequence + self.assertIn(self.txt_item, seq) + self.assertIn(self.code_item, seq) + + def test_type_guards(self): + with self.assertRaises(TypeError): + HistoryOfMedicationUse(medication_types_text='bad') + with self.assertRaises(TypeError): + HistoryOfMedicationUse(medication_types_code='bad') + with self.assertRaises(TypeError): + HistoryOfMedicationUse(medication_types_text=[123]) + + +class TestFamilyHistoryOfClinicalFinding(unittest.TestCase): + + def setUp(self): + super().setUp() + self.val = CodedConcept('FH', 'Diabetes', '99') + self.rel = Code('mother', 'Mother', '99') + + def test_construction(self): + itm = FamilyHistoryOfClinicalFinding(self.val, self.rel) + self.assertEqual(itm.ValueType, 'CODE') + self.assertTrue(any( + ci.ConceptNameCodeSequence[0].CodeValue == '408732007' + for ci in itm.ContentSequence)) + + def test_from_dataset(self): + itm = FamilyHistoryOfClinicalFinding(self.val, self.rel) + rebuilt = FamilyHistoryOfClinicalFinding.from_dataset(itm) + self.assertIsInstance(rebuilt, FamilyHistoryOfClinicalFinding) + + +class TestHistoryOfFamilyMemberDiseases(unittest.TestCase): + + def setUp(self): + super().setUp() + self.hist_strings = ['Father: myocardial infarction at 55'] + self.val = CodedConcept('FH', 'Diabetes', '99') + self.rel = Code('father', 'Father', '99') + self.fh_item = FamilyHistoryOfClinicalFinding(self.val, self.rel) + + def test_construction(self): + tpl = HistoryOfFamilyMemberDiseases() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_strings_only(self): + tpl = HistoryOfFamilyMemberDiseases(histories=self.hist_strings) + self.assertEqual(len(tpl[0].ContentSequence), len(self.hist_strings)) + + def test_findings_only(self): + tpl = HistoryOfFamilyMemberDiseases( + family_histories_of_clinical_findings=[self.fh_item] + ) + self.assertIn(self.fh_item, tpl[0].ContentSequence) + + def test_both(self): + tpl = HistoryOfFamilyMemberDiseases( + histories=self.hist_strings, + family_histories_of_clinical_findings=[self.fh_item], + ) + seq = tpl[0].ContentSequence + self.assertIn(self.fh_item, seq) + self.assertEqual(sum(ci.ValueType == 'TEXT' for ci in seq), 1) + + def test_type_guards(self): + with self.assertRaises(TypeError): + HistoryOfFamilyMemberDiseases(histories='bad') + with self.assertRaises(TypeError): + HistoryOfFamilyMemberDiseases(histories=[123]) + with self.assertRaises(TypeError): + HistoryOfFamilyMemberDiseases( + family_histories_of_clinical_findings='x') + with self.assertRaises(TypeError): + HistoryOfFamilyMemberDiseases( + family_histories_of_clinical_findings=[123]) + + +class TestMedicalDeviceUse(unittest.TestCase): + + def setUp(self): + super().setUp() + self.start = datetime.now() + self.end = datetime.now() + self.status = Code('active', 'Active', '99') + self.comment = 'No issues' + + def test_construction(self): + tpl = MedicalDeviceUse() + self.assertFalse(hasattr(tpl[0], 'ContentSequence')) + + def test_construction_with_optionals(self): + tpl = MedicalDeviceUse( + datetime_started=self.start, + datetime_ended=self.end, + status=self.status, + comment=self.comment, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any(ci.ValueType == 'DATETIME' for ci in seq)) + self.assertTrue(any(ci.ValueType == 'CODE' for ci in seq)) + self.assertTrue( + any(ci.ValueType == 'TEXT' and + ci.TextValue == self.comment for ci in seq)) + + +class TestHistoryOfMedicalDeviceUse(unittest.TestCase): + + def setUp(self): + super().setUp() + self.note = 'Pacemaker implanted 2018' + self.device_use = MedicalDeviceUse( + status=Code('active', 'Active', '99')) + + def test_note_only(self): + tpl = HistoryOfMedicalDeviceUse(history=self.note) + self.assertEqual(tpl[0].ContentSequence[0].TextValue, self.note) + + def test_devices_only(self): + tpl = HistoryOfMedicalDeviceUse(medical_device_uses=[self.device_use]) + self.assertIn(self.device_use[0], tpl[0].ContentSequence) + + def test_construction_with_optionals(self): + tpl = HistoryOfMedicalDeviceUse( + history=self.note, + medical_device_uses=[self.device_use], + ) + self.assertGreaterEqual(len(tpl[0].ContentSequence), 2) + + def test_type_guards(self): + with self.assertRaises(TypeError): + HistoryOfMedicalDeviceUse(medical_device_uses='x') + with self.assertRaises(TypeError): + HistoryOfMedicalDeviceUse(medical_device_uses=[123]) + + +class TestCardiovascularPatientHistory(unittest.TestCase): + + def setUp(self): + super().setUp() + self.note = 'Patient reports occasional chest pain' + self.problem_list = ProblemList() + self.social_history = SocialHistory() + self.past_surg = PastSurgicalHistory() + self.labs = RelevantDiagnosticTestsAndOrLaboratoryData() + self.med_use = HistoryOfMedicationUse( + medication_types_text=[MedicationTypeText( + 'Aspirin', + Code('active', 'Active', '99'))] + ) + self.family = HistoryOfFamilyMemberDiseases() + self.devices = HistoryOfMedicalDeviceUse( + history='Pacemaker implanted 2018' + ) + + def test_history_only(self): + tpl = CardiovascularPatientHistory(history=self.note) + seq = tpl[0].ContentSequence + self.assertEqual(seq[0].TextValue, self.note) + + def test_all_sections(self): + tpl = CardiovascularPatientHistory( + history=self.note, + problem_list=self.problem_list, + social_history=self.social_history, + past_surgical_history=self.past_surg, + relevant_diagnostic_tests_and_or_laboratory_data=self.labs, + history_of_medication_use=self.med_use, + history_of_family_member_diseases=self.family, + history_of_medical_device_use=self.devices, + ) + seq = tpl[0].ContentSequence + self.assertTrue(any(ci.TextValue == self.note for ci in seq)) + self.assertIn(next(iter(self.problem_list)), seq) + self.assertIn(next(iter(self.social_history)), seq) + self.assertIn(next(iter(self.past_surg)), seq) + self.assertIn(next(iter(self.labs)), seq) + self.assertIn(next(iter(self.med_use)), seq) + self.assertIn(next(iter(self.family)), seq) + self.assertIn(next(iter(self.devices)), seq) + + def test_type_guards(self): + with self.assertRaises(TypeError): + CardiovascularPatientHistory(problem_list='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory(social_history='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory(past_surgical_history='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory( + relevant_diagnostic_tests_and_or_laboratory_data='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory(history_of_medication_use='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory( + history_of_family_member_diseases='bad') + with self.assertRaises(TypeError): + CardiovascularPatientHistory(history_of_medical_device_use='bad')