From e4507fd93e6c28fa589a15c6ac2009aec39e3af9 Mon Sep 17 00:00:00 2001 From: gkennos Date: Wed, 5 Aug 2026 21:10:54 +1000 Subject: [PATCH] excluded parent concept support and a bit of fussing with surgery --- CHANGELOG.md | 7 +- docs/valuesets.md | 8 ++- pyproject.toml | 2 +- src/omop_semantics/runtime/renderers.py | 19 +++++- src/omop_semantics/runtime/value_sets.py | 48 +++++++++++-- .../schema/configuration/core/omop_base.yaml | 13 +++- .../generated_models/omop_named_sets.py | 2 + .../omop_semantic_registry.py | 2 + .../schema/instances/enumerators.yaml | 68 ++++++++++++++++++- .../schema/instances/valuesets.yaml | 9 ++- tests/test_default_valuesets.py | 39 +++++++++++ uv.lock | 2 +- 12 files changed, 204 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ec736..28f6547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,4 +66,9 @@ - fully additive; existing grounding and profile behavior is unchanged # 0.4.0 -- added Mermaid-based visualization for output-definition catalogues and projected bundles, and an optional interactive terminal explorer built on them \ No newline at end of file +- added Mermaid-based visualization for output-definition catalogues and projected bundles, and an optional interactive terminal explorer built on them + +# 0.5.0 +- brachy therapy +- excluded_parent_concepts support on OmopGroup +- new groups: cancer_indicating_surgery_parent_concepts, cancer_indicating_surgery_point_concepts \ No newline at end of file diff --git a/docs/valuesets.md b/docs/valuesets.md index c374b2a..0b524af 100644 --- a/docs/valuesets.md +++ b/docs/valuesets.md @@ -39,6 +39,11 @@ All labelled-concept types (`RuntimeEnum`, `RuntimeGroup`) expose: | `.labels` | sorted `list[str]` of labels | | `.mapper()` | `dict[str, int]` label → concept_id | +`RuntimeGroup` can also define excluded parent concepts for rules shaped as one +hierarchy closure minus another. Excluded labels are still addressable as +attributes, while `.ids` and `.mapper()` remain the included anchors only. Use +`.excluded_ids` or `.excluded_mapper()` for the excluded closure. + `RuntimeSemanticUnit` additionally exposes `.enums`, `.groups`, and `.concepts` as dictionaries for direct access to the underlying objects. ## What value sets are available @@ -53,7 +58,8 @@ The shipped value sets are defined in `instances/valuesets.yaml`. Current top-le | `treatment_modifiers` | Treatment intent, modality, and modifier values | | `condition_modifiers` | Condition modifier values, tumour grade, numeric modifiers, condition status | | `nlp` | Document type, encoding, and language | -| `cancer_procedures` | Consult types, provider specialties, procedure types, location | +| `cancer_procedures` | Consult types, provider specialties, broad procedure types, cancer-indicating surgery anchors, diagnostic/staging procedure anchors, location | +| `sact` | SACT drug inclusion and exclusion anchors | | `measurements_numeric` | Body size units and measurements, lab values, smoking, PROMs, performance status | | `staging` | T, N, M, and group stage concepts plus stage edition | | `visits` | Visit modalities | diff --git a/pyproject.toml b/pyproject.toml index b9be215..9e7eb47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omop-semantics" -version = "0.4.0" +version = "0.5.0" description = "Define, validate, and use schema-backed semantic conventions for OMOP CDM" readme = "README.md" authors = [ diff --git a/src/omop_semantics/runtime/renderers.py b/src/omop_semantics/runtime/renderers.py index db1806f..835fc6f 100644 --- a/src/omop_semantics/runtime/renderers.py +++ b/src/omop_semantics/runtime/renderers.py @@ -149,9 +149,15 @@ def render_semantic_object(obj: OmopSemanticObject | None) -> Html: for p in as_list(obj.parent_concepts) if p.concept_id is not None ) + exclusions = ", ".join( + f"{h(p.concept_id)} ({h(p.label)})" + for p in as_list(obj.excluded_parent_concepts) + if p.concept_id is not None + ) return Html( f"Group: {h(obj.name)}
" f"Anchors: {parents or '—'}" + + (f"
Excluded anchors: {exclusions}" if exclusions else "") ) if isinstance(obj, OmopEnum): @@ -184,9 +190,20 @@ def render_profile_object(obj: dict) -> Html: for a in anchors if isinstance(a, dict) ) + exclusions = as_list(obj.get("excluded_parent_concepts")) + exclusions_str = ", ".join( + f"{a.get('concept_id')} ({a.get('label')})" + for a in exclusions + if isinstance(a, dict) + ) return Html( f"Group: {h(obj.get('name'))}" + (f"
Anchors: {h(anchors_str)}" if anchors_str else "") + + ( + f"
Excluded anchors: {h(exclusions_str)}" + if exclusions_str + else "" + ) ) if class_uri == "OmopEnum": @@ -271,4 +288,4 @@ def render_profile_groups(profile: "SemanticProfileRuntime") -> Html: return Html(table( rows, header=["Name", "Role", "Notes", "Members"], - )) \ No newline at end of file + )) diff --git a/src/omop_semantics/runtime/value_sets.py b/src/omop_semantics/runtime/value_sets.py index f8e333e..08b9de3 100644 --- a/src/omop_semantics/runtime/value_sets.py +++ b/src/omop_semantics/runtime/value_sets.py @@ -101,15 +101,35 @@ class RuntimeGroup(_RuntimeLabelledConcepts): def __init__(self, group: OmopGroup): self._group = group self._name = group.name or '[group]' - self._by_label = { + self._included_by_label = { c.label: c.concept_id for c in (group.parent_concepts or []) if c and c.label and c.concept_id } + self._excluded_by_label = { + c.label: c.concept_id + for c in (group.excluded_parent_concepts or []) + if c and c.label and c.concept_id + } + self._by_label = self._included_by_label | self._excluded_by_label + + @property + def ids(self) -> set[int]: + return set(self._included_by_label.values()) + + @property + def excluded_ids(self) -> set[int]: + return set(self._excluded_by_label.values()) + + def mapper(self) -> dict[str, int]: + return dict(self._included_by_label) + + def excluded_mapper(self) -> dict[str, int]: + return dict(self._excluded_by_label) @property def is_singleton(self) -> bool: - return len(self._by_label) == 1 + return len(self._included_by_label) == 1 @property def value(self) -> int: @@ -120,7 +140,7 @@ def value(self) -> int: raise AttributeError( f"Group '{self._group.name}' has multiple parent concepts" ) - return next(iter(self._by_label.values())) + return next(iter(self._included_by_label.values())) def __int__(self) -> int: """ @@ -128,6 +148,20 @@ def __int__(self) -> int: """ return self.value + def _repr_html_(self) -> str: + rows = [ + tr([label, cid, "included"]) + for label, cid in sorted(self._included_by_label.items()) + ] + rows.extend( + tr([label, cid, "excluded"]) + for label, cid in sorted(self._excluded_by_label.items()) + ) + return Html( + f"

{h(self.kind_label)}: {h(self._name)}

" + + table(rows, header=["Label", "Concept ID", "Role"]) + ).raw + class RuntimeEnum(_RuntimeLabelledConcepts): """ @@ -236,7 +270,7 @@ def __getattr__(self, name: str): for value in labelled_item.values(): try: return getattr(value, name) - except KeyError: + except AttributeError: pass raise KeyError(name) @@ -258,8 +292,10 @@ def _repr_html_(self) -> str: for name in sorted(self.enums): rows.append(tr(["Enum", name, ", ".join(self.enums[name]._by_label.keys())])) for name, g in sorted(self.groups.items()): - if g._group.parent_concepts: - rows.append(tr(["Group", name, ", ".join(c.label for c in g._group.parent_concepts if c and c.label)])) + labels = list(g._included_by_label) + labels.extend(f"not {label}" for label in g._excluded_by_label) + if labels: + rows.append(tr(["Group", name, ", ".join(labels)])) for name in sorted(self.concepts): rows.append(tr(["Concept", name, ""])) diff --git a/src/omop_semantics/schema/configuration/core/omop_base.yaml b/src/omop_semantics/schema/configuration/core/omop_base.yaml index 8c5ae16..d91eff6 100644 --- a/src/omop_semantics/schema/configuration/core/omop_base.yaml +++ b/src/omop_semantics/schema/configuration/core/omop_base.yaml @@ -36,6 +36,7 @@ classes: T stage concepts. slots: - parent_concepts + - excluded_parent_concepts slot_usage: class_uri: equals_string: OmopGroup @@ -103,7 +104,16 @@ slots: parent_concepts: range: Concept multivalued: true - description: Semantic parent concepts or grouping parents. + description: Semantic parent concepts or grouping parents. + + excluded_parent_concepts: + range: Concept + multivalued: true + description: > + Parent concepts whose descendant closure is excluded from the positive + group membership. Use this when a governed value set is defined as one + hierarchy closure minus another, such as SACT drugs excluding supportive + medications. enum_members: range: Concept @@ -121,4 +131,3 @@ slots: class_uri: range: string required: true - diff --git a/src/omop_semantics/schema/generated_models/omop_named_sets.py b/src/omop_semantics/schema/generated_models/omop_named_sets.py index a61c27a..7b80639 100644 --- a/src/omop_semantics/schema/generated_models/omop_named_sets.py +++ b/src/omop_semantics/schema/generated_models/omop_named_sets.py @@ -91,6 +91,8 @@ class OmopGroup(OmopSemanticObject): 'name': 'class_uri'}}}) parent_concepts: Optional[list[Concept]] = Field(default=None, description="""Semantic parent concepts or grouping parents.""", json_schema_extra = { "linkml_meta": {'domain_of': ['OmopGroup']} }) + excluded_parent_concepts: Optional[list[Concept]] = Field(default=None, description="""Parent concepts whose descendant closure is excluded from the positive group membership. Use this when a governed value set is defined as one hierarchy closure minus another, such as SACT drugs excluding supportive medications. +""", json_schema_extra = { "linkml_meta": {'domain_of': ['OmopGroup']} }) class_uri: Literal["OmopGroup"] = Field(default=..., json_schema_extra = { "linkml_meta": {'domain_of': ['OmopSemanticObject'], 'equals_string': 'OmopGroup'} }) name: Optional[str] = Field(default=None, json_schema_extra = { "linkml_meta": {'domain_of': ['OmopSemanticObject', 'CDMSemanticUnits']} }) notes: Optional[str] = Field(default=None, json_schema_extra = { "linkml_meta": {'domain_of': ['OmopSemanticObject']} }) diff --git a/src/omop_semantics/schema/generated_models/omop_semantic_registry.py b/src/omop_semantics/schema/generated_models/omop_semantic_registry.py index e2c145f..5c04a0d 100644 --- a/src/omop_semantics/schema/generated_models/omop_semantic_registry.py +++ b/src/omop_semantics/schema/generated_models/omop_semantic_registry.py @@ -123,6 +123,8 @@ class OmopGroup(OmopSemanticObject): 'name': 'class_uri'}}}) parent_concepts: Optional[list[Concept]] = Field(default=None, description="""Semantic parent concepts or grouping parents.""", json_schema_extra = { "linkml_meta": {'domain_of': ['OmopGroup']} }) + excluded_parent_concepts: Optional[list[Concept]] = Field(default=None, description="""Parent concepts whose descendant closure is excluded from the positive group membership. Use this when a governed value set is defined as one hierarchy closure minus another, such as SACT drugs excluding supportive medications. +""", json_schema_extra = { "linkml_meta": {'domain_of': ['OmopGroup']} }) class_uri: Literal["OmopGroup"] = Field(default=..., json_schema_extra = { "linkml_meta": {'domain_of': ['OmopSemanticObject'], 'equals_string': 'OmopGroup'} }) name: str = Field(default=..., json_schema_extra = { "linkml_meta": {'domain_of': ['OmopSemanticObject', 'OmopCdmProfile', diff --git a/src/omop_semantics/schema/instances/enumerators.yaml b/src/omop_semantics/schema/instances/enumerators.yaml index 4a21470..25589e6 100644 --- a/src/omop_semantics/schema/instances/enumerators.yaml +++ b/src/omop_semantics/schema/instances/enumerators.yaml @@ -315,8 +315,75 @@ named_groups: label: rn_procedure - concept_id: 4141448 label: rt_externalbeam + - concept_id: 40317890 + label: rt_brachytherapy - concept_id: 37163499 label: rt_course + - name: cancer_indicating_surgery_parent_concepts + class_uri: OmopGroup + notes: > + More specific surgery anchors that can indicate cancer-directed surgery + in detailed procedure classification. These intentionally do not replace + the broad surgical_procedure anchor used when a construct needs all + surgery minus radiotherapy and radioisotope procedures. + parent_concepts: + - concept_id: 4000882 + label: lung_excision + - concept_id: 4041977 + label: gi_tract_excision + - concept_id: 4029565 + label: large_intestine_excision + - concept_id: 4027426 + label: kidney_excision + - concept_id: 4029571 + label: urinary_bladder_excision + - concept_id: 4250917 + label: prostate_operation + - concept_id: 4194253 + label: breast_operation + - concept_id: 4171687 + label: liver_operation + - concept_id: 4027422 + label: endocrine_system_excision + - concept_id: 4238646 + label: lymph_node_excision + - name: cancer_indicating_surgery_point_concepts + class_uri: OmopGroup + notes: > + Direct procedure concepts that can indicate cancer-directed surgery when + no useful OMOP ancestor captures the concept. + parent_concepts: + - concept_id: 4054047 + label: lobectomy + - name: diagnostic_staging_procedure_parent_concepts + class_uri: OmopGroup + notes: > + Diagnostic and staging procedures are kept separate from treatment + surgery because they establish extent of disease rather than treating it. + parent_concepts: + - concept_id: 4228202 + label: excisional_biopsy + - name: diagnostic_staging_procedure_point_concepts + class_uri: OmopGroup + notes: > + Direct diagnostic and staging procedure concepts that are not captured by + a useful OMOP ancestor. + parent_concepts: + - concept_id: 4120443 + label: bone_marrow_sampling + - name: sact_drug_classification + class_uri: OmopGroup + notes: > + Systemic anti-cancer therapy drug classification is ATC L + (antineoplastic and immunomodulating agents) minus HemOnc supportive + medication. This concept-identity rule deliberately leaves + dual-purpose corticosteroids on the supportive side. + parent_concepts: + - concept_id: 21601386 + label: atc_antineoplastic_and_immunomodulating + excluded_parent_concepts: + - concept_id: 35807271 + label: hemonc_supportive_medication - name: procedures_by_location class_uri: OmopGroup parent_concepts: @@ -398,4 +465,3 @@ named_groups: label: laterality - concept_id: 36769180 label: metastatic_disease - diff --git a/src/omop_semantics/schema/instances/valuesets.yaml b/src/omop_semantics/schema/instances/valuesets.yaml index 147fba9..e55db5e 100644 --- a/src/omop_semantics/schema/instances/valuesets.yaml +++ b/src/omop_semantics/schema/instances/valuesets.yaml @@ -33,7 +33,14 @@ valuesets: - cancer_consult_types - encounter_provider_specialty - cancer_procedure_types + - cancer_indicating_surgery_parent_concepts + - cancer_indicating_surgery_point_concepts + - diagnostic_staging_procedure_parent_concepts + - diagnostic_staging_procedure_point_concepts - procedures_by_location + - name: sact + semantic_units: + - sact_drug_classification - name: measurements_numeric semantic_units: - body_size_units @@ -58,4 +65,4 @@ valuesets: - sact_concepts - name: unknowns semantic_units: - - unknowns \ No newline at end of file + - unknowns diff --git a/tests/test_default_valuesets.py b/tests/test_default_valuesets.py index 85a972b..1bae508 100644 --- a/tests/test_default_valuesets.py +++ b/tests/test_default_valuesets.py @@ -14,3 +14,42 @@ def test_default_valuesets_expose_id_sets_for_downstream_use() -> None: assert 32533 in episode_types.ids assert 32949 in episode_types.ids assert "episode_of_care" in episode_types.labels + + +def test_cancer_procedure_groups_expose_governed_modality_anchors() -> None: + procedure_types = runtime.cancer_procedures.cancer_procedure_types + surgery_parents = runtime.cancer_procedures.cancer_indicating_surgery_parent_concepts + surgery_points = runtime.cancer_procedures.cancer_indicating_surgery_point_concepts + diagnostic_parents = runtime.cancer_procedures.diagnostic_staging_procedure_parent_concepts + diagnostic_points = runtime.cancer_procedures.diagnostic_staging_procedure_point_concepts + + assert procedure_types.surgical_procedure == 4301351 + assert procedure_types.rt_procedure == 1242725 + assert procedure_types.rt_externalbeam == 4141448 + assert procedure_types.rt_brachytherapy == 40317890 + + assert surgery_parents.mapper() == { + "lung_excision": 4000882, + "gi_tract_excision": 4041977, + "large_intestine_excision": 4029565, + "kidney_excision": 4027426, + "urinary_bladder_excision": 4029571, + "prostate_operation": 4250917, + "breast_operation": 4194253, + "liver_operation": 4171687, + "endocrine_system_excision": 4027422, + "lymph_node_excision": 4238646, + } + assert surgery_points.lobectomy == 4054047 + assert diagnostic_parents.excisional_biopsy == 4228202 + assert diagnostic_points.bone_marrow_sampling == 4120443 + + +def test_sact_drug_classification_exposes_inclusion_and_exclusion_anchors() -> None: + sact = runtime.sact.sact_drug_classification + + assert sact.atc_antineoplastic_and_immunomodulating == 21601386 + assert sact.hemonc_supportive_medication == 35807271 + assert sact.ids == {21601386} + assert sact.excluded_ids == {35807271} + assert sact.excluded_mapper() == {"hemonc_supportive_medication": 35807271} diff --git a/uv.lock b/uv.lock index 95c3673..d3ba38e 100644 --- a/uv.lock +++ b/uv.lock @@ -1408,7 +1408,7 @@ wheels = [ [[package]] name = "omop-semantics" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "ipykernel" },