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" },