From 5142a6aa6fe327f887b5ad6d5142e4c3ad253c99 Mon Sep 17 00:00:00 2001 From: Pip Liggins Date: Thu, 29 Aug 2024 14:34:46 +0100 Subject: [PATCH] Add a timingPhaseDetail extension (#67) * Create timingPhaseDetail extension * Use fixtures to test extension validation * Reduce duplicated code, add 'step_down' function and nested_extension property * Add docstrings and edit formatting of required function arguments --- fhirflat/flat2fhir.py | 344 +++++++++++++++++---- fhirflat/resources/condition.py | 29 +- fhirflat/resources/diagnosticreport.py | 21 +- fhirflat/resources/encounter.py | 27 +- fhirflat/resources/extension_types.py | 8 + fhirflat/resources/extension_validators.py | 10 + fhirflat/resources/extensions.py | 216 +++++++++++-- fhirflat/resources/immunization.py | 24 +- fhirflat/resources/observation.py | 24 +- fhirflat/resources/procedure.py | 29 +- fhirflat/util.py | 34 +- tests/conftest.py | 6 + tests/data/condition_flat.parquet | Bin 14975 -> 21206 bytes tests/data/immunization_flat.parquet | Bin 19931 -> 21111 bytes tests/fixtures/extensions.py | 106 +++++++ tests/test_condition_resource.py | 80 ++++- tests/test_diagnosticreport_resource.py | 25 ++ tests/test_encounter_resource.py | 15 + tests/test_extensions.py | 76 ++++- tests/test_flat2fhir_units.py | 179 +++++++++++ tests/test_immunization_resource.py | 131 ++++---- tests/test_observation_resource.py | 25 ++ tests/test_procedure_resource.py | 26 +- 23 files changed, 1222 insertions(+), 213 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/extensions.py diff --git a/fhirflat/flat2fhir.py b/fhirflat/flat2fhir.py index 20ec377..977317e 100644 --- a/fhirflat/flat2fhir.py +++ b/fhirflat/flat2fhir.py @@ -1,25 +1,68 @@ # Converts FHIRflat files into FHIR resources -from fhir.resources.backbonetype import BackboneType as _BackboneType from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.datatype import DataType as _DataType from fhir.resources.domainresource import DomainResource as _DomainResource from fhir.resources.fhirprimitiveextension import FHIRPrimitiveExtension from fhir.resources.period import Period from fhir.resources.quantity import Quantity from pydantic.v1.error_wrappers import ValidationError +from .resources.extensions import _ISARICExtension from .util import ( - find_data_class, + find_data_class_options, get_fhirtype, get_local_extension_type, group_keys, + json_type_matching, ) +def step_down(data: dict) -> dict: + """ + Splits column names on the first '.' to step 'down' one level into the nested data. + + Parameters + ---------- + data + { + "timingPhaseDetail.timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhaseDetail.timingPhase.text": ["pre-admission"], + } + + Returns + ------- + dict + { + "timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhase.text": ["pre-admission"], + } + """ + return {s.split(".", 1)[1]: data[s] for s in data} + + def create_codeable_concept( old_dict: dict[str, list[str] | str | float | None], name: str ) -> dict[str, list[str]]: - """Re-creates a codeableConcept structure from the FHIRflat representation.""" + """ + Re-creates a codeableConcept structure from the FHIRflat representation. + + Parameters + ---------- + old_dict + The dictionary containing the flattened codings and text. E.g., + {"bodySite.code": ["SNOMED-CT|123456"], "bodySite.text": "Left arm"} + name + The base name of the data, e.g. "bodySite" + + Returns + ------- + dict + The FHIR representation of the codeableConcept. E.g., + { + "bodySite": { + "coding": [{"system": "SNOMED-CT", "code": "123456", "display": "Left arm"}] + } + } + """ # for creating backbone elements if name + ".code" in old_dict and name + ".system" in old_dict: @@ -83,7 +126,29 @@ def create_codeable_concept( return new_dict -def createQuantity(df, group): +def create_quantity(df: dict, group: str) -> dict: + """ + Re-creates a Quantity structure from the FHIRflat representation. + Ensures that any flattened codes are correctly unpacked. + + Parameters + ---------- + df + The dictionary containing the flattened quantity data. E.g., + { + "doseQuantity.value": 5, + "doseQuantity.code": "http://unitsofmeasure.org|mg" + } + group + The base name of the data, e.g. "doseQuantity" + + Returns + ------- + dict + The FHIR representation of the quantity. E.g., + {"value": 5, "system": "http://unitsofmeasure.org", "code": "mg"} + """ + quant = {} for attribute in df.keys(): @@ -103,78 +168,223 @@ def createQuantity(df, group): return quant -def createExtension(exts: dict): +def create_single_extension(k: str, v: dict | str | float | bool) -> dict: """ - Searches through the schema of the extensions to find the correct datatype - - Covers the scenario where there is a list of extensions,e.g. - [{'type': 'approximateDate'}, {'type': 'relativeDay'}, {'type': 'Extension'}] - and finds the appropriate class for the data provided. + Creates a single ISARIC extension, by inferring the datatype from the value. + Nested extensions aren't dealt with here, they're found in 'create_extension'. + + Parameters + ---------- + k + The key of the data, e.g. "approximateDate", "birthSex", "timingDetail" + v + The value of the data, e.g. + "month 3", + {"code": ["http://snomed.info/sct|1234"], "text": ["female"]}, + {"low.value": -7, "low.unit": "days", "high.value": 0, "high.unit": "days"} + + Returns + ------- + dict + The formatted data, e.g., + {'url': 'approximateDate', 'valueString': 'month 3'} \ + { + "url": "birthSex", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1234", + "display": "female", + } + ] + }, + } \ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + } - Args: - exts: dict - e.g. {"relativeDay": 3, "approximateDate": "month 6"} """ - extensions = [] + klass = get_local_extension_type(k) - extension_classes = {e: get_local_extension_type(e) for e in exts.keys()} + prop = klass.schema()["properties"] + value_type = [key for key in prop.keys() if key.startswith("value")] - for e, v in exts.items(): - properties = extension_classes[e].schema()["properties"] - data_options = [key for key in properties.keys() if key.startswith("value")] - if len(data_options) == 1: - extensions.append({"url": e, data_options[0]: v}) - else: - for opt in data_options: + if not value_type: # pragma: no cover + raise RuntimeError("Inappropriate entry into create_single_extension") + + for v_type in value_type: + data_type = prop[v_type]["type"] + try: + data_class = get_fhirtype(data_type) + # unpack coding etc + if isinstance(v, dict) and len(group_keys(v.keys())) > 1: + # if there are still groups to organise, e.g. valueRange + new_dict = expand_concepts(v, data_class) + elif isinstance(v, dict): + # single group needs formatting, e.g. valueCodeableConcept + # requires the format to include the k in the dict names + v_appended = {f"{k}.{ki}": vi for ki, vi in v.items()} + new_dict = set_datatypes(k, v_appended, data_class) + else: + # standard json type, e.g. valueInteger + new_dict = v + + try: + data_class.parse_obj(new_dict) + return {"url": k, f"{v_type}": new_dict} + except ValidationError: + continue + except AttributeError as e: + # should be a standard json type as a string + if isinstance(v, json_type_matching(data_type)): try: - extension_classes[e](**{opt: v}) - extensions.append({"url": e, opt: v}) - break + klass.parse_obj({"url": k, f"{v_type}": v}) + return {"url": k, f"{v_type}": v} except ValidationError: continue + else: + raise e # pragma: no cover + + raise RuntimeError(f"extension not created from {k, v}") # pragma: no cover + + +def create_extension(k: str, v_dict: dict, klass: _ISARICExtension) -> dict: + """ + Formats ISARIC extensions into the correct FHIR structure, while finding the correct + value type for the data. + Can handle both nested and simple extensions. + + Parameters + ---------- + k + The key of the data, e.g. "timingPhaseDetail" + v_dict + The value of the data, e.g. + { + "timingDetail.high.unit": "days", + "timingDetail.high.value": 0.0, + "timingDetail.low.unit": "days", + "timingDetail.low.value": -7.0, + "timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhase.text": ["pre-admission"], + } + klass + The class of the data, e.g. . + + Returns + ------- + dict + The formatted data, e.g. + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + ], + } + """ + + if klass.nested_extension: + classes = find_data_class_options(klass, "extension") + short_extensions = [s for s in v_dict.keys() if s.count(".") == 0] + expanded_short_extensions = [] + if short_extensions: + # these get skipped over in expand_concepts because they don't get grouped + # so have to be dealt with here + for se in short_extensions: + short_ext_dict = {se: v_dict[se]} + expanded_short_extensions.append( + create_single_extension(se, short_ext_dict[se]) + ) + v_dict.pop(se) + return { + "url": k, + "extension": list(expand_concepts(v_dict, classes).values()) + + expanded_short_extensions, + } + + return create_single_extension(k, v_dict) - return extensions +def set_datatypes(k: str, v_dict: dict, klass: type[_DomainResource]) -> dict: + """ + Once the final datatype is found, this function formats the data into the correct + FHIR structure. + + Parameters + ---------- + k + The key of the data, e.g. "bodySite" + v_dict + The value of the data, e.g. + {"bodySite.code": ["SNOMED-CT|123456"], "bodySite.text": "Left arm"} + klass + The class of the data, e.g. Quantity, CodeableConcept. Should be present in + either this library, or the fhir.resources module. + + Returns + ------- + dict + The formatted data, e.g. + { + "bodySite": { + "coding": [{"system": "SNOMED-CT", "code": "123456", "display": "Left arm"}] + } + } + """ -def set_datatypes(k, v_dict, klass) -> dict: if klass == Quantity: - return createQuantity(v_dict, k) + return create_quantity(v_dict, k) elif klass == CodeableConcept: return create_codeable_concept(v_dict, k) elif klass == Period: return {"start": v_dict.get(k + ".start"), "end": v_dict.get(k + ".end")} elif issubclass(klass, FHIRPrimitiveExtension): + stripped_dict = step_down(v_dict) return { - "extension": createExtension( - {s.split(".", 1)[1]: v_dict[s] for s in v_dict} - ), + "extension": [ + create_single_extension(ki, vi) for ki, vi in stripped_dict.items() + ], } - elif issubclass(klass, _DataType) and not issubclass(klass, _BackboneType): - # not quite - prop = klass.schema()["properties"] - value_type = [key for key in prop.keys() if key.startswith("value")] - if not value_type: - # nested extension + elif issubclass(klass, _ISARICExtension): + if klass.nested_extension: + stripped_dict = step_down(v_dict) return { "url": k, - "extension": createExtension( - {s.split(".", 1)[1]: v_dict[s] for s in v_dict} - ), + "extension": [ + create_single_extension(ki, vi) for ki, vi in stripped_dict.items() + ], } - data_type = prop[value_type[0]]["type"] - try: - data_class = get_fhirtype(data_type) - return {"url": k, f"{value_type[0]}": set_datatypes(k, v_dict, data_class)} - except AttributeError: - # datatype should be a primitive - return {"url": k, f"{value_type[0]}": v_dict[k]} + return create_single_extension(k, step_down(v_dict)) - return {s.split(".", 1)[1]: v_dict[s] for s in v_dict} + return step_down(v_dict) -def expand_concepts(data: dict[str, str], data_class: type[_DomainResource]) -> dict: +def expand_concepts(data: dict[str, dict], data_class: type[_DomainResource]) -> dict: """ Combines columns containing flattened FHIR concepts back into JSON-like structures. @@ -184,24 +394,35 @@ def expand_concepts(data: dict[str, str], data_class: type[_DomainResource]) -> group_classes = {} for k in groups.keys(): - group_classes[k] = find_data_class(data_class, k) + group_classes[k] = find_data_class_options(data_class, k) expanded = {} keys_to_replace = [] + for k, v in groups.items(): + is_single_fhir_extension = not isinstance( + group_classes[k], list + ) and issubclass(group_classes[k], _ISARICExtension) keys_to_replace += v v_dict = {k: data[k] for k in v} + # step into nested groups if any(s.count(".") > 1 for s in v): # strip the outside group name - stripped_dict = {s.split(".", 1)[1]: v_dict[s] for s in v} - # call recursively - new_v_dict = expand_concepts(stripped_dict, data_class=group_classes[k]) - # add outside group key back on - v_dict = {f"{k}." + old_k: v for old_k, v in new_v_dict.items()} + stripped_dict = step_down(v_dict) + if not is_single_fhir_extension: + # call recursively + new_v_dict = expand_concepts(stripped_dict, data_class=group_classes[k]) + # add outside group key back on + v_dict = {f"{k}." + old_k: v for old_k, v in new_v_dict.items()} + elif is_single_fhir_extension: + # column name will be missing one or more datatype layers, e.g. + # valueString, valueRange that need to be inferred + expanded[k] = create_extension(k, stripped_dict, group_classes[k]) + continue if all(isinstance(v, dict) for v in v_dict.values()): # coming back out of nested recursion - expanded[k] = {s.split(".", 1)[1]: v_dict[s] for s in v_dict} + expanded[k] = step_down(v_dict) elif any(isinstance(v, dict) for v in v_dict.values()) and isinstance( group_classes[k], list @@ -210,14 +431,11 @@ def expand_concepts(data: dict[str, str], data_class: type[_DomainResource]) -> non_dict_items = { k: v for k, v in v_dict.items() if not isinstance(v, dict) } - stripped_dict = { - s.split(".", 1)[1]: non_dict_items[s] for s in non_dict_items.keys() - } + stripped_dict = step_down(non_dict_items) for k1, v1 in stripped_dict.items(): - klass = find_data_class(group_classes[k], k1) - v_dict[k + "." + k1] = set_datatypes(k1, {k1: v1}, klass) + v_dict[k + "." + k1] = create_single_extension(k1, v1) - expanded[k] = {s.split(".", 1)[1]: v_dict[s] for s in v_dict} + expanded[k] = step_down(v_dict) else: expanded[k] = set_datatypes(k, v_dict, group_classes[k]) diff --git a/fhirflat/resources/condition.py b/fhirflat/resources/condition.py index c65dbf0..dcd86e8 100644 --- a/fhirflat/resources/condition.py +++ b/fhirflat/resources/condition.py @@ -8,8 +8,20 @@ from pydantic.v1 import Field, validator from .base import FHIRFlatBase -from .extension_types import presenceAbsenceType, prespecifiedQueryType, timingPhaseType -from .extensions import presenceAbsence, prespecifiedQuery, timingPhase +from .extension_types import ( + presenceAbsenceType, + prespecifiedQueryType, + timingPhaseDetailType, + # timingDetailType, + timingPhaseType, +) +from .extensions import ( + presenceAbsence, + prespecifiedQuery, + timingPhase, + # timingDetail, + timingPhaseDetail, +) JsonString: TypeAlias = str @@ -22,6 +34,7 @@ class Condition(_Condition, FHIRFlatBase): presenceAbsenceType, prespecifiedQueryType, timingPhaseType, + timingPhaseDetailType, fhirtypes.ExtensionType, ] ] = Field( @@ -61,11 +74,17 @@ def validate_extension_contents(cls, extensions): present_count = sum(isinstance(item, presenceAbsence) for item in extensions) query_count = sum(isinstance(item, prespecifiedQuery) for item in extensions) timing_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) - if present_count > 1 or query_count > 1 or timing_count > 1: + if present_count > 1 or query_count > 1 or timing_count > 1 or detail_count > 1: raise ValueError( - "presenceAbsence, prespecifiedQuery and timingPhase can only appear" - " once." + "presenceAbsence, prespecifiedQuery, timingPhase and timingPhaseDetail " + "can only appear once." + ) + + if timing_count > 0 and detail_count > 0: + raise ValueError( + "timingPhase and timingPhaseDetail cannot appear together." ) return extensions diff --git a/fhirflat/resources/diagnosticreport.py b/fhirflat/resources/diagnosticreport.py index ad4a7f9..8de5640 100644 --- a/fhirflat/resources/diagnosticreport.py +++ b/fhirflat/resources/diagnosticreport.py @@ -15,12 +15,14 @@ from fhirflat.flat2fhir import expand_concepts from .base import FHIRFlatBase -from .extension_types import timingPhaseType -from .extensions import timingPhase +from .extension_types import timingPhaseDetailType, timingPhaseType +from .extensions import timingPhase, timingPhaseDetail class DiagnosticReport(_DiagnosticReport, FHIRFlatBase): - extension: list[Union[timingPhaseType, fhirtypes.ExtensionType]] = Field( + extension: list[ + Union[timingPhaseType, timingPhaseDetailType, fhirtypes.ExtensionType] + ] = Field( None, alias="extension", title="List of `Extension` items (represented as `dict` in JSON)", @@ -51,11 +53,20 @@ class DiagnosticReport(_DiagnosticReport, FHIRFlatBase): @validator("extension") def validate_extension_contents(cls, extensions): - tim_phase_count = sum(isinstance(item, timingPhase) for item in extensions) + timing_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) - if tim_phase_count > 1: + if timing_count > 1: raise ValueError("timingPhase can only appear once.") + if detail_count > 1: + raise ValueError("timingPhaseDetail can only appear once.") + + if timing_count > 0 and detail_count > 0: + raise ValueError( + "timingPhase and timingPhaseDetail cannot appear together." + ) + return extensions @classmethod diff --git a/fhirflat/resources/encounter.py b/fhirflat/resources/encounter.py index a98ad39..6270d75 100644 --- a/fhirflat/resources/encounter.py +++ b/fhirflat/resources/encounter.py @@ -14,15 +14,20 @@ from pydantic.v1 import Field, validator from .base import FHIRFlatBase -from .extension_types import relativePeriodType, timingPhaseType -from .extensions import relativePeriod, timingPhase +from .extension_types import relativePeriodType, timingPhaseDetailType, timingPhaseType +from .extensions import relativePeriod, timingPhase, timingPhaseDetail JsonString: TypeAlias = str class Encounter(_Encounter, FHIRFlatBase): extension: list[ - Union[relativePeriodType, timingPhaseType, fhirtypes.ExtensionType] + Union[ + relativePeriodType, + timingPhaseType, + timingPhaseDetailType, + fhirtypes.ExtensionType, + ] ] = Field( None, alias="extension", @@ -64,10 +69,18 @@ class Encounter(_Encounter, FHIRFlatBase): @validator("extension") def validate_extension_contents(cls, extensions): rel_phase_count = sum(isinstance(item, relativePeriod) for item in extensions) - tim_phase_count = sum(isinstance(item, timingPhase) for item in extensions) - - if rel_phase_count > 1 or tim_phase_count > 1: - raise ValueError("relativePeriod and timingPhase can only appear once.") + timing_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) + + if rel_phase_count > 1 or timing_count > 1 or detail_count > 1: + raise ValueError( + "relativePeriod, timingPhase and timingPhaseDetail can only appear once." # noqa E501 + ) + + if timing_count > 0 and detail_count > 0: + raise ValueError( + "timingPhase and timingPhaseDetail cannot appear together." + ) return extensions diff --git a/fhirflat/resources/extension_types.py b/fhirflat/resources/extension_types.py index bc2b2c9..c0a8b3e 100644 --- a/fhirflat/resources/extension_types.py +++ b/fhirflat/resources/extension_types.py @@ -20,6 +20,14 @@ class timingPhaseType(AbstractType): __resource_type__ = "timingPhase" +class timingDetailType(AbstractType): + __resource_type__ = "timingDetail" + + +class timingPhaseDetailType(AbstractType): + __resource_type__ = "timingPhaseDetail" + + class relativeDayType(AbstractType): __resource_type__ = "relativeDay" diff --git a/fhirflat/resources/extension_validators.py b/fhirflat/resources/extension_validators.py index 322f0b8..d156a39 100644 --- a/fhirflat/resources/extension_validators.py +++ b/fhirflat/resources/extension_validators.py @@ -56,6 +56,8 @@ class Validators: def __init__(self): self.MODEL_CLASSES = { "timingPhase": (None, ".extensions"), + "timingDetail": (None, ".extensions"), + "timingPhaseDetail": (None, ".extensions"), "relativeDay": (None, ".extensions"), "relativeStart": (None, ".extensions"), "relativeEnd": (None, ".extensions"), @@ -202,6 +204,14 @@ def timingphase_validator(v: Union[StrBytes, dict, Path, FHIRAbstractModel]): return Validators().fhir_model_validator("timingPhase", v) +def timingdetail_validator(v: Union[StrBytes, dict, Path, FHIRAbstractModel]): + return Validators().fhir_model_validator("timingDetail", v) + + +def timingphasedetail_validator(v: Union[StrBytes, dict, Path, FHIRAbstractModel]): + return Validators().fhir_model_validator("timingPhaseDetail", v) + + def relativeday_validator(v: Union[StrBytes, dict, Path, FHIRAbstractModel]): return Validators().fhir_model_validator("relativeDay", v) diff --git a/fhirflat/resources/extensions.py b/fhirflat/resources/extensions.py index dc20ee9..d3b9ffb 100644 --- a/fhirflat/resources/extensions.py +++ b/fhirflat/resources/extensions.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any, Union +from typing import Any, ClassVar, Union from fhir.resources import fhirtypes from fhir.resources.datatype import DataType as _DataType @@ -18,10 +18,33 @@ from . import extension_types as et +# --------- base local extension type --------------- + + +class _ISARICExtension(_DataType): + """ + Base class for all ISARIC extensions. + """ + + resource_type: str = Field(default="ISARICExtension", const=True) + + url: str = Field(None, alias="url", title="URI of the extension", const=True) + + nested_extension: ClassVar[bool] = False + + @classmethod + def elements_sequence(cls): + """returning all elements names from + ``Extension`` according specification, + with preserving original sequence order. + """ + return ["id", "extension", "url"] + + # --------- extensions ------------------------------ -class timingPhase(_DataType): +class timingPhase(_ISARICExtension): """ An ISARIC extension collecting data on the phase of admission an event occurred. This is typically one of: @@ -62,7 +85,160 @@ def elements_sequence(cls): ] -class relativeDay(_DataType): +class timingDetail(_ISARICExtension): + """ + An ISARIC extension collecting more detail on the timingPhase. This is typically + means providing a range of days relative to the admission date (valueRange), noting + that this covers a period since the last encounter (valueCodeableConcept), or note + that this is for the last 12 months (valueString). + + To denote a range **before** the timingPhase, use a negative number. + e.g. -1 to -3 would denote 1 to 3 days before the timingPhase. + """ + + resource_type: str = Field(default="timingDetail", const=True) + + url: str = Field("timingDetail", const=True, alias="url") + + valueRange: fhirtypes.RangeType = Field( + None, + alias="valueRange", + title="Value of extension", + description=( + "Value of extension - must be one of a constrained set of the data " + "types (see [Extensibility](extensibility.html) for a list)." + ), + # if property is element of this resource. + element_property=True, + # Choice of Data Types. i.e value[x] + one_of_many="value", + one_of_many_required=True, + ) + + valueCodeableConcept: fhirtypes.CodeableConceptType = Field( + None, + alias="valueCodeableConcept", + title="Value of extension", + description=( + "Value of extension - must be one of a constrained set of the data " + "types (see [Extensibility](extensibility.html) for a list)." + ), + # if property is element of this resource. + element_property=True, + # Choice of Data Types. i.e value[x] + one_of_many="value", + one_of_many_required=True, + ) + + valueString: fhirtypes.String = Field( + None, + alias="valueString", + title="Value of extension", + description=( + "Value of extension - must be one of a constrained set of the data " + "types (see [Extensibility](extensibility.html) for a list)." + ), + # if property is element of this resource. + element_property=True, + # Choice of Data Types. i.e value[x] + one_of_many="value", + one_of_many_required=True, + ) + + @classmethod + def elements_sequence(cls): + """returning all elements names from + ``Extension`` according specification, + with preserving original sequence order. + """ + return [ + "id", + "extension", + "url", + "valueRange", + "valueCodeableConcept", + "valueString", + ] + + @root_validator(pre=True, allow_reuse=True) + def validate_one_of_many_1136(cls, values: dict[str, Any]) -> dict[str, Any]: + """https://www.hl7.org/fhir/formats.html#choice + A few elements have a choice of more than one data type for their content. + All such elements have a name that takes the form nnn[x]. + The "nnn" part of the name is constant, and the "[x]" is replaced with + the title-cased name of the type that is actually used. + The table view shows each of these names explicitly. + + Elements that have a choice of data type cannot repeat - they must have a + maximum cardinality of 1. When constructing an instance of an element with a + choice of types, the authoring system must create a single element with a + data type chosen from among the list of permitted data types. + """ + one_of_many_fields = { + "value": [ + "valueRange", + "valueCodeableConcept", + "valueString", + ] + } + for prefix, fields in one_of_many_fields.items(): + assert cls.__fields__[fields[0]].field_info.extra["one_of_many"] == prefix + required = ( + cls.__fields__[fields[0]].field_info.extra["one_of_many_required"] + is True + ) + found = False + for field in fields: + if field in values and values[field] is not None: + if found is True: + raise ValueError( + "Any of one field value is expected from " + f"this list {fields}, but got multiple!" + ) + else: + found = True + if required is True and found is False: + raise ValueError(f"Expect any of field value from this list {fields}.") + + return values + + +class timingPhaseDetail(_ISARICExtension): + """ + An ISARIC extension collecting data on the phase of admission an event occurred. + This combines the `timingPhase`, `timingDetail` and `timingPoint` to bundle the + responses relative to eachother. + """ + + resource_type: str = Field(default="timingPhaseDetail", const=True) + + url: str = Field("timingPhaseDetail", const=True, alias="url") + + nested_extension: ClassVar[bool] = True + + extension: list[Union[et.timingPhaseType, et.timingDetailType]] = Field( + None, + alias="extension", + title="List of `Extension` items (represented as `dict` in JSON)", + description="Additional content defined by implementations", + # if property is element of this resource. + element_property=True, + # this trys to match the type of the object to each of the union types + union_mode="smart", + ) + + @validator("extension") + def validate_extension_contents(cls, extensions): + time_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingDetail) for item in extensions) + + if time_count > 1 or detail_count > 1: + raise ValueError("timingPhase and timingDetail can only appear once.") + + return extensions + + +class relativeDay(_ISARICExtension): """ An ISARIC extension recording the day an event occurred relative to the admission date. For a resources such as Encounter or Procedure, use relativePeriod to record @@ -100,7 +276,7 @@ def elements_sequence(cls): ] -class relativeStart(_DataType): +class relativeStart(_ISARICExtension): """ An ISARIC extension for use inside the complex `relativePeriod` extension. """ @@ -136,7 +312,7 @@ def elements_sequence(cls): ] -class relativeEnd(_DataType): +class relativeEnd(_ISARICExtension): """ An ISARIC extension for use inside the complex `relativePeriod` extension. """ @@ -172,7 +348,7 @@ def elements_sequence(cls): ] -class relativePeriod(_DataType): +class relativePeriod(_ISARICExtension): """ An ISARIC extension recording the start and end dates an event occurred relative to the admission date. @@ -189,6 +365,8 @@ class relativePeriod(_DataType): url: str = Field("relativePeriod", const=True, alias="url") + nested_extension: ClassVar[bool] = True + extension: list[Union[et.relativeStartType, et.relativeEndType]] = Field( None, alias="extension", @@ -210,20 +388,8 @@ def validate_extension_contents(cls, extensions): return extensions - @classmethod - def elements_sequence(cls): - """returning all elements names from - ``Extension`` according specification, - with preserving original sequence order. - """ - return [ - "id", - "extension", - "url", - ] - -class approximateDate(_DataType): +class approximateDate(_ISARICExtension): """ An ISARIC extension for recording the approximate date (if the true date is unknown) or timeframe of an event. @@ -316,7 +482,7 @@ def validate_one_of_many_1136(cls, values: dict[str, Any]) -> dict[str, Any]: return values -class Duration(_DataType): +class Duration(_ISARICExtension): """ An ISARIC extension for recording the length of an event (e.g. 5 days) where duration is not an option in the base FHIR specification. @@ -353,7 +519,7 @@ def elements_sequence(cls): ] -class Age(_DataType): +class Age(_ISARICExtension): """ An ISARIC extension collecting data on the age of a patient. """ @@ -389,7 +555,7 @@ def elements_sequence(cls): ] -class birthSex(_DataType): +class birthSex(_ISARICExtension): """ An ISARIC extension collecting data on the birth sex of a patient. """ @@ -425,7 +591,7 @@ def elements_sequence(cls): ] -class Race(_DataType): +class Race(_ISARICExtension): """ An ISARIC extension collecting data on the race of a patient. """ @@ -461,7 +627,7 @@ def elements_sequence(cls): ] -class presenceAbsence(_DataType): +class presenceAbsence(_ISARICExtension): """ An ISARIC extension to indicate if a clinical finding is present, absent or unknown. """ @@ -497,7 +663,7 @@ def elements_sequence(cls): ] -class prespecifiedQuery(_DataType): +class prespecifiedQuery(_ISARICExtension): """ An ISARIC extension to indicate if a finding is the result of a prespecified query. """ diff --git a/fhirflat/resources/immunization.py b/fhirflat/resources/immunization.py index d80a73c..da415c5 100644 --- a/fhirflat/resources/immunization.py +++ b/fhirflat/resources/immunization.py @@ -13,14 +13,20 @@ from pydantic.v1 import Field, validator from .base import FHIRFlatBase -from .extension_types import dateTimeExtensionType, timingPhaseType -from .extensions import timingPhase +from .extension_types import ( + dateTimeExtensionType, + timingPhaseDetailType, + timingPhaseType, +) +from .extensions import timingPhase, timingPhaseDetail JsonString: TypeAlias = str class Immunization(_Immunization, FHIRFlatBase): - extension: list[Union[timingPhaseType, fhirtypes.ExtensionType]] = Field( + extension: list[ + Union[timingPhaseType, timingPhaseDetailType, fhirtypes.ExtensionType] + ] = Field( None, alias="extension", title="List of `Extension` items (represented as `dict` in JSON)", @@ -69,10 +75,16 @@ class Immunization(_Immunization, FHIRFlatBase): @validator("extension") def validate_extension_contents(cls, extensions): - phase_count = sum(isinstance(item, timingPhase) for item in extensions) + timing_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) + + if timing_count > 1 or detail_count > 1: + raise ValueError("timingPhase and timingPhaseDetail can only appear once.") - if phase_count > 1: - raise ValueError("timingPhase can only appear once.") + if timing_count > 0 and detail_count > 0: + raise ValueError( + "timingPhase and timingPhaseDetail cannot appear together." + ) return extensions diff --git a/fhirflat/resources/observation.py b/fhirflat/resources/observation.py index aeb7623..4b4277d 100644 --- a/fhirflat/resources/observation.py +++ b/fhirflat/resources/observation.py @@ -14,8 +14,12 @@ from pydantic.v1 import Field, validator from .base import FHIRFlatBase -from .extension_types import dateTimeExtensionType, timingPhaseType -from .extensions import timingPhase +from .extension_types import ( + dateTimeExtensionType, + timingPhaseDetailType, + timingPhaseType, +) +from .extensions import timingPhase, timingPhaseDetail JsonString: TypeAlias = str @@ -33,7 +37,9 @@ class ObservationComponent(_ObservationComponent): class Observation(_Observation, FHIRFlatBase): - extension: list[Union[timingPhaseType, fhirtypes.ExtensionType]] = Field( + extension: list[ + Union[timingPhaseType, timingPhaseDetailType, fhirtypes.ExtensionType] + ] = Field( None, alias="extension", title="List of `Extension` items (represented as `dict` in JSON)", @@ -94,10 +100,16 @@ class Observation(_Observation, FHIRFlatBase): @validator("extension") def validate_extension_contents(cls, extensions): - phase_count = sum(isinstance(item, timingPhase) for item in extensions) + timing_count = sum(isinstance(item, timingPhase) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) + + if timing_count > 1 or detail_count > 1: + raise ValueError("timingPhase and timingPhaseDetail can only appear once.") - if phase_count > 1: - raise ValueError("timingPhase can only appear once.") + if timing_count > 0 and detail_count > 0: + raise ValueError( + "timingPhase and timingPhaseDetail cannot appear together." + ) return extensions diff --git a/fhirflat/resources/procedure.py b/fhirflat/resources/procedure.py index 8297d5d..695266e 100644 --- a/fhirflat/resources/procedure.py +++ b/fhirflat/resources/procedure.py @@ -17,9 +17,10 @@ dateTimeExtensionType, durationType, relativePeriodType, + timingPhaseDetailType, timingPhaseType, ) -from .extensions import Duration, relativePeriod, timingPhase +from .extensions import Duration, relativePeriod, timingPhase, timingPhaseDetail JsonString: TypeAlias = str @@ -27,7 +28,11 @@ class Procedure(_Procedure, FHIRFlatBase): extension: list[ Union[ - durationType, timingPhaseType, relativePeriodType, fhirtypes.ExtensionType + durationType, + timingPhaseType, + timingPhaseDetailType, + relativePeriodType, + fhirtypes.ExtensionType, ] ] = Field( None, @@ -35,8 +40,9 @@ class Procedure(_Procedure, FHIRFlatBase): title="Additional content defined by implementations", description=( """ - Contains the G.H 'timingPhase', 'relativePeriod' and 'duration' extensions, - and allows extensions from other implementations to be included.""" + Contains the G.H 'timingPhase', 'timingPhaseDetail', 'relativePeriod' and + 'duration' extensions, and allows extensions from other implementations to + be included.""" ), # if property is element of this resource. element_property=True, @@ -79,10 +85,21 @@ def validate_extension_contents(cls, extensions): duration_count = sum(isinstance(item, Duration) for item in extensions) tim_phase_count = sum(isinstance(item, timingPhase) for item in extensions) rel_phase_count = sum(isinstance(item, relativePeriod) for item in extensions) + detail_count = sum(isinstance(item, timingPhaseDetail) for item in extensions) + + if ( + duration_count > 1 + or tim_phase_count > 1 + or rel_phase_count > 1 + or detail_count > 1 + ): + raise ValueError( + "duration, timingPhase, timingPhaseDetail and relativePeriod can only appear once." # noqa E501 + ) - if duration_count > 1 or tim_phase_count > 1 or rel_phase_count > 1: + if tim_phase_count > 0 and detail_count > 0: raise ValueError( - "duration, timingPhase and relativePeriod can only appear once." + "timingPhase and timingPhaseDetail cannot appear together." ) return extensions diff --git a/fhirflat/util.py b/fhirflat/util.py index b475236..176170f 100644 --- a/fhirflat/util.py +++ b/fhirflat/util.py @@ -86,23 +86,23 @@ def get_local_resource(t: str, case_insensitive: bool = False): return getattr(fhirflat, a) -def find_data_class( +def find_data_class_options( data_class: FHIRFlatBase | list[FHIRFlatBase], k: str -) -> FHIRFlatBase: +) -> FHIRFlatBase | list[FHIRFlatBase]: """ - Finds the type class for item k within the data class. + Finds the type class(es) for item k within the data class. Parameters ---------- - data_class: list[BaseModel] or BaseModel + data_class The data class to search within. If a list, the function will search for the a class with a matching title to k. - k: str + k The property to search for within the data class """ if isinstance(data_class, list): - title_matches = [k.lower() == c.schema()["title"].lower() for c in data_class] + title_matches = [k.lower() == c.__name__.lower() for c in data_class] result = [x for x, y in zip(data_class, title_matches, strict=True) if y] if len(result) == 1: return get_fhirtype(k) @@ -110,7 +110,7 @@ def find_data_class( raise ValueError(f"Couldn't find a matching class for {k} in {data_class}") else: - k_schema = data_class.schema()["properties"].get(k) + k_schema = data_class.schema()["properties"][k] base_class = ( k_schema.get("items").get("type") @@ -122,6 +122,7 @@ def find_data_class( assert k_schema.get("type") == "array" base_class = [opt.get("type") for opt in k_schema["items"]["anyOf"]] + return get_fhirtype(base_class) @@ -129,7 +130,7 @@ def code_or_codeable_concept( col_name: str, resource: FHIRFlatBase | list[FHIRFlatBase] ) -> bool: search_terms = col_name.split(".") - fhir_type = find_data_class(resource, search_terms[0]) + fhir_type = find_data_class_options(resource, search_terms[0]) if len(search_terms) == 2: # e.g. "code.code", "age.code" schema = fhir_type.schema()["properties"] @@ -205,3 +206,20 @@ def condense_codes(row: pd.Series, code_col: str) -> pd.Series: row[code_col + ".code"] = codes return row + + +def json_type_matching(t: str): + """ + Matches a JSON type to a Python type. + """ + tps = { + "string": str, + "integer": float, + "number": float, + "boolean": bool, + "array": list, + "object": dict, + "null": None, + } + + return tps[t] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..afa779f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + +from fixtures.extensions import ( + raises_phase_plus_detail_error, + raises_phase_duplicate_error, +) diff --git a/tests/data/condition_flat.parquet b/tests/data/condition_flat.parquet index f213484721ce977ee26bc82d686915dabc7ac32c..1b1c4d8e70b1500557dd0f3abcd928fb41d24276 100644 GIT binary patch delta 4742 zcmcgvU2Gdw7M>Z$A#v+8DIRy+I*n_{B#|A@_%Dg0rsa;4IF9VpcI!AcX~p;_w!_%5 zYCB0&A?L9?unW38>_dfM)$ZG}uvziI6XF3?i>P=&2mvkd!ow~?Wu-+bLcqNf+l>uv z?3P_CnVECv+;hJB-E+Qc|6Ka`I{C&)Ri&5K_-KkI4iiL|zJnl$q3*9b-#vmQwj*b}eryWpmklI*<{I zsp(Wn%u3afs*%a2GnR7xM7BgzT1vZ3YOj#&pGVmL3^!$iZ_+y$_Vy62i&8m7>oLTO+AVh5%-%NX8)$MurlA5-^Jys`d!n{9WxcK09*Ky_74)!NI zB-Q<0^;C_)(G~_#jTvdSR%3lnsSz24i>XmDnadV&Nh9ix)5eCbP&YU*jV45p9bZqj z4LJp0)6f=5`!1>dC&}J+u~%JqYa4?%=fXvJTh$A_swwK_Q}BxF3FWmZ_Q#X(4b^4p z_EYeSBSz)7r`h*TL-K$g77naX|8T=k4j7d;1ojUC{Q1E9)Kw3Bf#H=mX4o&M5n0!% zkLKZkj#u9CvhR4|Ep>%@#}6;`^eb=p*egD`-2#$0B;gQt2L1gYD&YJU& zEU){htLFUgE0n+1NzL*jE8T;IPV>K%F3tZ@WHimO8f#1w3k zz!(*5lfu{(Y?H*CI;y=nsHNC0?e>s{AarJ?vmoY^Vv)IeeCOQJ*E-=d2GwwDUp}%W zc-f#IYS9@6^=DfSj?X`69q4ZfiR>&6XdX+Z64{(6!8cC~=`S16*^XW$D4)q|au z2QvK+ijsog8IRyF`*`&}aen8&hHHv`oAEhUFWKi_XZpFeODe0Ll#NGArnb=Y& zBStpLqDRV$Zl^V#lX&&S;sp!jB$N74wH8GN0^W_)I z;!+UynJ%|IGnR?ZEyC-jp@*FL!N{@hkQB8^rD$XW_L&`nR1zc3eAtu8By5XyE^Fg; z(sG;Y$xBskZ;t3xn5@Xcddw3>aF^NzQRmCi@*WlA_Cz_En=K|I&@gmV74s~vqRPm` z-SAb;&dwFHiMe@9;g%S2mA$KLY5&=cv^T%78jWm9vOQ;oIn#i7pOn>d@#XO9V6R1o zi89v+xgSxxYB>gPnX_zbli;gE23+Z*`|adz2b^{Cv84Lr-dLuZ^O99upPwR76xcAke)_% z7@+w9;79X;8?_-Ev2z{MX;T0$+an71IVXI}KH3eooE+!!n^xdA_LJQLFs*Q0I*^A} z$7q)r$aCD-suQj_rn=^_BbPA2e~($ZX0V$OxCp;;jCW7-*jhl=38$d&td7AI=Uini zaFOE>0ubDgA%-wcjIJsR0sQ2~4uX`L3rX?(LLqK*lv|@YjQkcINgM;XdDA*tLp@&@ zCxKIr6BRoi+uR!GxCt~g<3}{1n~Y%wKgw{hPytZH7rYg*h*K`Qy0Y9)aT-cXULx=Z z5D@!xEg!r!&Q@kuk!gyW5P2wrAZ+49f}CEKM{ELc318Yue^2oCG!ud`>qD6)ZA5(#O^emQlrKv8&`wH|P0m7kXxB$n60$bHRPsk*n1E7N5$(KznM-r+!2VU{c>&}%9 fati7uS(1U`Bn=Cboe%myh=0`0pCX6=2w8stNPEtd delta 669 zcmcb%l<|Mb1{EAUHxH? z?-_+Q+e>n>V)KMBy5sfH9j?QyhQsB;*j%lQ?qXwfm)c8ykriW7;}H8MC&IuWsUyKw zkeHW}SS)cuWV58oLblC)`b(H)4U}pbBzPQyg8aj+ARdx`Tw{f%vzJsrI# z_ZutmIYm0BJ32Z!0)cC~_vZb^w>UO)I^SmTbM~?X2|7AP0qi7sP3H14=XCM~@v?wCFE1x%UyuebkImnG(^yzRE^wS27o^50G P87IRJEe3`F#~?!h=A+Qn diff --git a/tests/data/immunization_flat.parquet b/tests/data/immunization_flat.parquet index 258ec455265af7981725a113c911ec5448c53d8e..9480ac889f4f86e47d18fe5b5d0c1e2735973842 100644 GIT binary patch delta 2961 zcmb_cZ){sv6}O*bCr+FuwPRzqPS(WDOH$i;wi73IS32H{{p>h#^6bTbVrfPGlh{dZ zCnRaoMnGZO#F(_A^&rGVq)AYf398DKt9~djHa_eFu#QRy(3tomrU}p(`+&wIP{p~| zcAA7R^#f0OSNGg|&hPy0Ilp^<`z`rDugNP96j17F7n)=;*}~?+nqH>Y>J2tMqwmzK z^eXe1Y_N%}8|jEn>Jto(j9kvh*aw|vy~gC0sX9%@!K((n%eSzRUMdc6ER~kZ`I@0; z42H|S_IfA#QO5y9kehYzFU8C4&+B)wJT}Bq)xx3DG^D(vWAACZo%lqt1qa>ETd zd#{_l+6@=Y!(eG%V(#?9m1djr<_Y$_$Kc1!Utzv?5`ry*%DWc!ss-LrJK=gumZ=Ot zo4Q+hgJs|A2fO-N=IK*#TWwKZ9b*4Fh+f}ecI@!w)-mNXHukm^_*MtJ-}-6hLk_HM zCz%}%$-Th5?SenFv8``7*^eEhG&*>_RR?$54B*wQFdw+#smJ=1AC0niMyvNV<8tQa z)ei3HBFf>87*8RIPL<<;-&;sC#Yd zKToi43v{RV=qQ{x`ZBWm^eXMdP!rqpkv+D=U?Kr&IHK4o}WB)M+FLv4BibZSGH<|uD=$08( z>)FCWDXn+O^qPLgXn}Np7rb?{1J?A6cBLQE6_*xK|Ep!x{M=yE<$5 zCL=e!7|B;>O%-|j|3#7Add(w_a);?=e5J6uE;kJgs04=x|uU9uT2N z!5&Z{`Wzxdt%7~1LuB;{I{b?Qo%ae6?rZQ+pCCl^KTL^G>98x<|DnVn(%dga>tQm~ z9h6^6!5*T&F5{4aVOtCjPIbv-CYw>Unl9(k>&72;SMOSWs?Z%GH~8wnz@h7ZwClhz zFxPmGE)IAa(`y4?Y)lPFfr9yfgBHX#^_XmI8((UT|g3PGlqtV&K>Dq-bzVmY%k#Xz<2SuETWxZ6DoxQq+%cahu1}YvtC>XcLdp>><^tks z2L3iadMs8qCfts6!nv9$<@~8+w2)40!Qao6;NvlKO9KF!#zX4(?)DSo-`!*V8uxaw z;9kg39rQRgmzP3bLWbw(@fWt{6Ylf#$uLgOqwUKI$aazJ`6B`1MQjHjl;Tv^z@6g1z192(2)+L9m1tla%MA^3@xYPQFk^# zYr(IP_${9Y=KPk+mr@CFJL4L?kS)basbr{_Db3VsGD|Z!IH_KK+I0S4`Ds%ywlF_} zm!pQWl^F>+QVCk_7>Q3y+x2$D=;91zNTwP(t93mHPoFvUBwY}fJsf9UZ{(h!O(W9W z*Ln)GH+L!^I#L97HiPk`Iy^rrZDYTBPLp~Bg+eY+EN03PeuGk4_STCySt}xC_ppcp zQKJB#9WjnzZ8LReRJJs-?>u|tMN;D3!65*xr@JqsxaTm=JId{sahCYijzwm*3S;Vh0R;4242^4EKn$9T`{!dqdDTu`yvE7B#o16V6zg6j%`#Y zr5L7<4!!SniX@qjQeKDP)CpVTE!=%fox;j|a;7+4n%PX%6*?!OxW{}}c-L={TF!SrRJ;Op1LcCT)1Cuf?MHUcrC20|0=bta7HGR_^9(g D-B@bA delta 2317 zcma)6ZERCj81C-8+qw_g%DQzM>psdZ+uH56+q$)kDW{)nw|2LMwreAVt@~d00c9&| zVkU_QK}F4r1a%NmWBj8mR)_&KnkXSeg&$~$B!(D&7&T%r;-8_O_jZ%g><1_5z31Hb z<9Xiqc~Ad-C*#%|8O#49e-W{65hn3p%hEomkD zf{MFb3-_gmm>)SfT%}^~)^S(sM6d62=a`iS@Z`0#Kh<-`>!B!53zzb`nClwYQ@xp4 zQBxi-G8cGQ&ga;7o4K1!aIJbfR21xDzG;DYK`r~8jys`CoGIwYV2&AJx~7Reujig? zgEwoI;JpopnM+-uD{NwqY~z+YVQ;My-Y>k#{A_|xYc=eZ?c9YY;ZE%{V2}lv6&t)F z)38Tu+;uCwt!jkdWJ}ENPPndWWluY}kGr9HqYd1fWl&!H8uLXToGa$o6&H8h1-FYY zGRJqoDft%mSuc0P0|Rmm{3S0b-lCT%vZCR!;i&<+RwB=@V-zZw(B zO=X!}s;Cq?O;z%cqHv~wl1)?eRJxiy=}I0~9kb9#E1r4cpz$6vb#f7!FMwwuMv0k67cth0v3&?=rW-F&gR*MFwq#SijDICW;iJ+(L1gJb_*KR)-_;D(tQSPNnnwqm`&VuhsJ^}Zc4y!8Eo-JdZ*m; zAv^65T?4{Cp6`gm4QI2|iD~&s^*rSDs49^{w|jQT?wcF3``d=l+a)scsd@O-p^;i< zkbxk~L$$M6_8@6cP16k0&wHFrIUa%f=y!I&t4>vA4<-pu8Fi^y!{}k49^X2fvYit+ zCsC00=!(`dy6~GJ%nOL>u*5LESBUdGRgQRkVy|cy@gP3&L8Ybiosn>hQ5T+aNBrH9 zSSV>8+YUA2ilBF=US<^YokM!_(eK2<;Ax=PCc>oS4LkAfl9oDT5;F1z&Dg@4z4uN9YBM zaTVH;gceN?pv5c?LDRe8ji6UxzBSn)V)F4Lv~`t>bi`=;?KbT|z&}R?o#97#eneRH z^NfmhIngPllUC$qH%>F>5g&^){`Rm@<)=bCKRFx&{Nb zxuF0Z$yP6w#=p=z?Tm!zI~34QV8SURq>ir@ZWF6Dq1Jc_*A}ueMF(5lV`9(6=2@&r zz%I~n5l15^79>`U1<^Y%E;AbKk+`qbKOVLlwG>!O1njiY%F|h-R@3U85>Zp`lvt5n z>0UBxV*l55Ra|HT~1e&LzY- rfukMe;TK0!!Y-W8$iX;5y1L-Lr!GSQ#M=adw?ZmeGDsu>@#_8qiz&rU diff --git a/tests/fixtures/extensions.py b/tests/fixtures/extensions.py new file mode 100644 index 0000000..1a19fd1 --- /dev/null +++ b/tests/fixtures/extensions.py @@ -0,0 +1,106 @@ +import pytest + + +@pytest.fixture +def raises_phase_plus_detail_error(): + def _ext_test(fhir_input, resource): + fhir_input["extension"] = [ + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + ], + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": 278307001, + "display": "on admission", + } + ] + }, + }, + ] + source = fhir_input + with pytest.raises(ValueError, match="cannot appear together"): + resource(**source) + + return _ext_test + + +@pytest.fixture +def raises_phase_duplicate_error(): + def _ext_test(fhir_input, resource): + fhir_input["extension"] = [ + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + ], + }, + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": 278307001, + "display": "on admission", + } + ] + }, + }, + { + "url": "timingDetail", + "valueString": "ever", + }, + ], + }, + ] + source = fhir_input + with pytest.raises(ValueError, match="can only appear once"): + resource(**source) + + return _ext_test diff --git a/tests/test_condition_resource.py b/tests/test_condition_resource.py index e8b94c1..a0f440d 100644 --- a/tests/test_condition_resource.py +++ b/tests/test_condition_resource.py @@ -22,6 +22,30 @@ }, }, {"url": "prespecifiedQuery", "valueBoolean": True}, + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + ], + }, ], "identifier": [{"value": "12345"}], "clinicalStatus": { @@ -110,6 +134,14 @@ "extension.presenceAbsence.code": ["http://snomed.info/sct|410605003"], "extension.presenceAbsence.text": ["Present"], "extension.prespecifiedQuery": True, + "extension.timingPhaseDetail.timingPhase.code": [ + "http://snomed.info/sct|281379000" + ], + "extension.timingPhaseDetail.timingPhase.text": ["pre-admission"], + "extension.timingPhaseDetail.timingDetail.low.value": -7, + "extension.timingPhaseDetail.timingDetail.low.unit": "days", + "extension.timingPhaseDetail.timingDetail.high.value": 0, + "extension.timingPhaseDetail.timingDetail.high.unit": "days", "category.code": [ "http://snomed.info/sct|55607006", "http://terminology.hl7.org/CodeSystem/condition-category|problem-list-item", # noqa: E501 @@ -143,6 +175,30 @@ ] }, }, + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + ], + }, ], "clinicalStatus": { "coding": [ @@ -212,7 +268,7 @@ def test_condition_to_flat(): expected = expected.reindex(sorted(expected.columns), axis=1) # v, e = Condition.validate_fhirflat(expected) - assert_frame_equal(fever_flat, expected) + assert_frame_equal(fever_flat, expected, check_dtype=False) os.remove("test_condition.parquet") @@ -271,3 +327,25 @@ def test_condition_extension_validation_error(): def test_from_flat_validation_error_single(): with pytest.raises(ValidationError, match="1 validation error for Condition"): Condition.from_flat("tests/data/condition_flat_missing_subject.parquet") + + +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "id": "c201", + "subject": {"reference": "Patient/f201"}, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", # noqa: E501 + "code": "resolved", + } + ] + }, + } + raises_phase_plus_detail_error(fhir_input, Condition) + raises_phase_duplicate_error(fhir_input, Condition) diff --git a/tests/test_diagnosticreport_resource.py b/tests/test_diagnosticreport_resource.py index 11b6527..51e8f45 100644 --- a/tests/test_diagnosticreport_resource.py +++ b/tests/test_diagnosticreport_resource.py @@ -3,6 +3,7 @@ import os from fhirflat.resources.diagnosticreport import DiagnosticReport import datetime +import pytest DICT_INPUT = { @@ -175,3 +176,27 @@ def test_observation_from_flat(): flat_report = DiagnosticReport.from_flat("tests/data/diagnosticreport_flat.parquet") assert report == flat_report + + +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "resourceType": "DiagnosticReport", + "id": "f001", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "58410-2", + "display": "Complete blood count (hemogram) panel - Blood by Automated count", # noqa: E501 + } + ] + }, + } + raises_phase_plus_detail_error(fhir_input, DiagnosticReport) + raises_phase_duplicate_error(fhir_input, DiagnosticReport) diff --git a/tests/test_encounter_resource.py b/tests/test_encounter_resource.py index 967745f..9c827ff 100644 --- a/tests/test_encounter_resource.py +++ b/tests/test_encounter_resource.py @@ -425,3 +425,18 @@ def test_from_flat_validation_error_multi_resources(): assert len(errors) == 1 assert "invalid datetime format" in errors.iloc[0]["validation_error"] os.remove("encounter_errors.csv") + + +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "resourceType": "Encounter", + "id": "f203", + "status": "completed", + } + raises_phase_plus_detail_error(fhir_input, Encounter) + raises_phase_duplicate_error(fhir_input, Encounter) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 49de855..3217117 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -7,6 +7,8 @@ from fhir.resources.quantity import Quantity as _Quantity from fhirflat.resources.extensions import ( timingPhase, + timingDetail, + timingPhaseDetail, relativeDay, relativeStart, relativeEnd, @@ -37,6 +39,76 @@ def test_timingPhase(): assert timing_phase.resource_type == "timingPhase" assert timing_phase.url == "timingPhase" assert type(timing_phase.valueCodeableConcept) is _CodeableConcept + assert timing_phase.nested_extension is False + + +@pytest.mark.parametrize( + "data", + [ + { + "url": "timingDetail", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "708353007", + "display": "Since last encounter (qualifier value)", + } + ] + }, + }, + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + {"url": "timingDetail", "valueString": "Ever"}, + ], +) +def test_timingDetail(data): + timing_detail = timingDetail(**data) + assert isinstance(timing_detail, DataType) + assert timing_detail.resource_type == "timingDetail" + assert timing_detail.url == "timingDetail" + + +tpd_data = { + "url": "timingPhaseDetail", + "extension": [timing_phase_data, {"url": "timingDetail", "valueString": "Ever"}], +} + + +def test_timingPhaseDetail(): + timing_phase_detail = timingPhaseDetail(**tpd_data) + assert isinstance(timing_phase_detail, DataType) + assert timing_phase_detail.resource_type == "timingPhaseDetail" + assert timing_phase_detail.url == "timingPhaseDetail" + assert timing_phase_detail.nested_extension is True + + +tpd_data_error = { + "url": "timingPhaseDetail", + "extension": [ + timing_phase_data, + {"url": "timingDetail", "valueString": "Ever"}, + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + ], +} + + +def test_timingPhaseDetail_error(): + with pytest.raises( + ValidationError, match="timingPhase and timingDetail can only appear once" + ): + _ = timingPhaseDetail(**tpd_data_error) rel_day = {"url": "relativeDay", "valueInteger": 3} @@ -85,6 +157,7 @@ def test_relativePeriod(): isinstance(ext, (relativeStart, relativeEnd)) for ext in relative_phase.extension ) + assert relative_phase.nested_extension is True @pytest.mark.parametrize( @@ -163,8 +236,9 @@ def test_extension_name_error(ext_class, data): (approximateDate, {"valueDate": "2021-09", "valueString": "month 3"}), (Duration, {"valuePeriod": "middle"}), (dateTimeExtension, {"extension": [{"valueDate": "month 3"}]}), + (timingDetail, {"valueString": "ever", "valueRange": {}}), ], ) def test_extension_validation_error(ext_class, data): with pytest.raises(ValidationError): - ext_class(**data)(**data) + ext_class(**data) diff --git a/tests/test_flat2fhir_units.py b/tests/test_flat2fhir_units.py index 7a26f78..b9ee7f9 100644 --- a/tests/test_flat2fhir_units.py +++ b/tests/test_flat2fhir_units.py @@ -1,6 +1,7 @@ import fhirflat.flat2fhir as f2f import pytest from fhir.resources.encounter import Encounter +from fhirflat.resources.extensions import timingPhaseDetail @pytest.mark.parametrize( @@ -169,3 +170,181 @@ def test_expand_concepts(data_class, expected): result = f2f.expand_concepts(data, data_class) assert result == expected + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + { + "timingPhaseDetail.timingPhase.code": [ + "http://snomed.info/sct|281379000" + ], + "timingPhaseDetail.timingPhase.text": ["pre-admission"], + }, + { + "timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhase.text": ["pre-admission"], + }, + ), + ], +) +def test_step_down(data, expected): + assert f2f.step_down(data) == expected + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + ( + "doseQuantity", + { + "doseQuantity.value": 5, + "doseQuantity.code": "http://unitsofmeasure.org|mg", + }, + ), + { + "value": 5, + "system": "http://unitsofmeasure.org", + "code": "mg", + }, + ), + ], +) +def test_create_quantity(data, expected): + group_name, data = data + assert f2f.create_quantity(data, group_name) == expected + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + ( + "approximateDate", + "month 3", + ), + {"url": "approximateDate", "valueString": "month 3"}, + ), + ( + ( + "birthSex", + {"code": ["http://snomed.info/sct|1234"], "text": ["female"]}, + ), + { + "url": "birthSex", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1234", + "display": "female", + } + ] + }, + }, + ), + ( + ( + "timingDetail", + { + "low.value": -7, + "low.unit": "days", + "high.value": 0, + "high.unit": "days", + }, + ), + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + ), + ], +) +def test_create_single_extension(data, expected): + k, v = data + assert f2f.create_single_extension(k, v) == expected + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + ( + "timingPhaseDetail", + { + "timingDetail.high.unit": "days", + "timingDetail.high.value": 0.0, + "timingDetail.low.unit": "days", + "timingDetail.low.value": -7.0, + "timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhase.text": ["pre-admission"], + }, + timingPhaseDetail, + ), + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingDetail", + "valueRange": { + "low": {"value": -7, "unit": "days"}, + "high": {"value": 0, "unit": "days"}, + }, + }, + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "281379000", + "display": "pre-admission", + } + ] + }, + }, + ], + }, + ), + ( + ( + "timingPhaseDetail", + { + "timingDetail": "ever", + "timingPhase.code": ["http://snomed.info/sct|281379000"], + "timingPhase.text": ["pre-admission"], + }, + timingPhaseDetail, + ), + { + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": 278307001, + "display": "on admission", + } + ] + }, + }, + { + "url": "timingDetail", + "valueString": "ever", + }, + ], + }, + ), + ], +) +def test_create_extension(data, expected): + k, v_dict, klass = data + f2f.create_extension(k, v_dict, klass) == expected diff --git a/tests/test_immunization_resource.py b/tests/test_immunization_resource.py index 82a530b..a5dab1e 100644 --- a/tests/test_immunization_resource.py +++ b/tests/test_immunization_resource.py @@ -17,16 +17,25 @@ "status": "completed", "extension": [ { - "url": "timingPhase", - "valueCodeableConcept": { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": 278307001, - "display": "on admission", - } - ] - }, + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": 278307001, + "display": "on admission", + } + ] + }, + }, + { + "url": "timingDetail", + "valueString": "ever", + }, + ], }, ], "vaccineCode": { @@ -104,8 +113,9 @@ IMMUNIZATION_FLAT = { "resourceType": "Immunization", - "extension.timingPhase.code": "http://snomed.info/sct|278307001", - "extension.timingPhase.text": "on admission", + "extension.timingPhaseDetail.timingPhase.code": "http://snomed.info/sct|278307001", + "extension.timingPhaseDetail.timingPhase.text": "on admission", + "extension.timingPhaseDetail.timingDetail": "ever", "occurrenceDateTime": datetime.date(2021, 9, 12), "_occurrenceDateTime.relativeDay": 3.0, "_occurrenceDateTime.approximateDate": "month 3", @@ -133,16 +143,25 @@ "status": "completed", "extension": [ { - "url": "timingPhase", - "valueCodeableConcept": { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": 278307001, - "display": "on admission", - } - ] - }, + "url": "timingPhaseDetail", + "extension": [ + { + "url": "timingPhase", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": 278307001, + "display": "on admission", + } + ] + }, + }, + { + "url": "timingDetail", + "valueString": "ever", + }, + ], }, ], "vaccineCode": { @@ -219,48 +238,26 @@ def test_immunization_from_flat(): assert vacc == flat_vacc -def test_immunization_extension_validation_error(): - with pytest.raises(ValueError, match="timingPhase can only appear once."): - Immunization( - **{ - "id": 2, - "status": "in-progress", - "extension": [ - { - "url": "timingPhase", - "valueCodeableConcept": { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": 278307001, - "display": "on admission", - } - ] - }, - }, - { - "url": "timingPhase", - "valueCodeableConcept": { - "coding": [ - { - "system": "http://snomed.info/sct", - "code": 278307005, - "display": "during admission", - } - ] - }, - }, - ], - "patient": {"reference": "Patient/example"}, - "occurrenceDateTime": "2021-09-12T00:00:00", - "vaccineCode": { - "coding": [ - { - "system": "http://hl7.org/fhir/sid/cvx", - "code": "175", - "display": "Rabies - IM Diploid cell culture", - } - ], - }, - } - ) +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "id": 2, + "status": "in-progress", + "patient": {"reference": "Patient/example"}, + "occurrenceDateTime": "2021-09-12T00:00:00", + "vaccineCode": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/cvx", + "code": "175", + "display": "Rabies - IM Diploid cell culture", + } + ], + }, + } + raises_phase_plus_detail_error(fhir_input, Immunization) + raises_phase_duplicate_error(fhir_input, Immunization) diff --git a/tests/test_observation_resource.py b/tests/test_observation_resource.py index 23198ee..423f380 100644 --- a/tests/test_observation_resource.py +++ b/tests/test_observation_resource.py @@ -3,6 +3,7 @@ import os from fhirflat.resources.observation import Observation import datetime +import pytest # TODO: extra observation with a single component for travel. @@ -382,3 +383,27 @@ def test_observation_from_flat(): flat_bp = Observation.from_flat("tests/data/observation_flat.parquet") assert bp == flat_bp + + +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "85354-9", + "display": "Blood pressure panel with all children optional", + } + ], + "text": "Blood pressure systolic & diastolic", + }, + } + raises_phase_plus_detail_error(fhir_input, Observation) + raises_phase_duplicate_error(fhir_input, Observation) diff --git a/tests/test_procedure_resource.py b/tests/test_procedure_resource.py index f6cff27..b9b4335 100644 --- a/tests/test_procedure_resource.py +++ b/tests/test_procedure_resource.py @@ -165,16 +165,16 @@ def test_procedure_from_flat(): assert chemo == flat_chemo -def test_procedure_extension_validation_error(): - with pytest.raises(ValueError, match="can only appear once"): - Procedure( - **{ - "id": 1, - "status": "completed", - "subject": {"reference": "Patient/example"}, - "extension": [ - {"url": "duration", "valueQuantity": {"value": 1, "unit": "d"}}, - {"url": "duration", "valueQuantity": {"value": 2, "unit": "d"}}, - ], - } - ) +@pytest.mark.usefixtures( + "raises_phase_plus_detail_error", "raises_phase_duplicate_error" +) +def test_extension_raises_errors( + raises_phase_plus_detail_error, raises_phase_duplicate_error +): + fhir_input = { + "id": 1, + "status": "completed", + "subject": {"reference": "Patient/example"}, + } + raises_phase_plus_detail_error(fhir_input, Procedure) + raises_phase_duplicate_error(fhir_input, Procedure)