Skip to content

Commit eeea021

Browse files
committed
feat: implement relativeTime extension
1 parent d2ea3d6 commit eeea021

File tree

17 files changed

+641
-102
lines changed

17 files changed

+641
-102
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ default_language_version:
33
exclude: '^docs/'
44
repos:
55
- repo: https://github.com/pycqa/isort
6-
rev: 5.13.2
6+
rev: 6.0.1
77
hooks:
88
- id: isort
99
name: isort (python)
@@ -17,13 +17,13 @@ repos:
1717
- id: check-json
1818
- id: pretty-format-json
1919
args: ['--autofix', '--no-sort-keys']
20-
- repo: https://github.com/ambv/black
21-
rev: 24.10.0
20+
- repo: https://github.com/psf/black
21+
rev: 25.1.0
2222
hooks:
2323
- id: black
2424
language_version: python3.11
2525
- repo: https://github.com/pre-commit/mirrors-mypy
26-
rev: 'v1.13.0'
26+
rev: 'v1.15.0'
2727
hooks:
2828
- id: mypy
2929
name: mypy
@@ -36,12 +36,12 @@ repos:
3636
exclude: tests/
3737
args: [--select, "D101,D102,D103,D105,D106"]
3838
- repo: https://github.com/PyCQA/bandit
39-
rev: '1.8.0'
39+
rev: '1.8.3'
4040
hooks:
4141
- id: bandit
4242
args: [--skip, "B101,B303,B110,B311"]
4343
- repo: https://github.com/PyCQA/flake8
44-
rev: '7.1.1'
44+
rev: '7.1.2'
4545
hooks:
4646
- id: flake8
4747
- repo: https://github.com/myint/autoflake

converter/parser/util.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import logging
2+
from typing import Callable, cast
3+
4+
from execution_engine.omop.criterion.abstract import Criterion
5+
from execution_engine.omop.criterion.procedure_occurrence import ProcedureOccurrence
6+
from execution_engine.util import logic
7+
from execution_engine.util.temporal_logic_util import Presence
8+
9+
10+
def _wrap_criteria_with_factory(
11+
expr: logic.BaseExpr,
12+
factory: Callable[[logic.BaseExpr], logic.TemporalCount],
13+
) -> logic.Expr:
14+
"""
15+
Recursively wraps all Criterion instances within a combination using the specified factory.
16+
17+
:param expr: A single Criterion or an expression to be processed.
18+
:param factory: A callable that takes a Criterion or expression and returns a TemporalCount.
19+
:return: A new TemporalCount where all Criterion instances have been wrapped using the factory.
20+
:raises ValueError: If an unexpected element type is encountered.
21+
"""
22+
23+
from digipod.concepts import OMOP_SURGICAL_PROCEDURE
24+
25+
new_expr: logic.Expr
26+
27+
if isinstance(expr, Criterion):
28+
new_expr = factory(expr)
29+
elif isinstance(expr, logic.Expr):
30+
31+
# Create a new combination of the same type with the same operator
32+
args = []
33+
34+
# Loop through all elements
35+
for element in expr.args:
36+
if isinstance(element, logic.Expr):
37+
# Recursively wrap nested combinations
38+
args.append(_wrap_criteria_with_factory(element, factory))
39+
elif isinstance(element, Criterion):
40+
# Wrap individual criteria with the factory
41+
42+
if (
43+
isinstance(element, ProcedureOccurrence)
44+
and element.concept.concept_id == OMOP_SURGICAL_PROCEDURE
45+
and element.concept.vocabulary_id == "SNOMED"
46+
):
47+
logging.warning(
48+
"Removing Surgical Procedure Criterion in TimeFromEvent-SurgicalOperationDate"
49+
)
50+
continue
51+
52+
args.append(factory(element))
53+
54+
else:
55+
raise ValueError(f"Unexpected element type: {type(element)}")
56+
57+
new_expr = expr.__class__(*args)
58+
else:
59+
raise ValueError(f"Unexpected element type: {type(expr)}")
60+
61+
return new_expr
62+
63+
64+
def wrap_criteria_with_temporal_indicator(
65+
expr: logic.BaseExpr,
66+
interval_criterion: logic.BaseExpr,
67+
) -> logic.TemporalMinCount:
68+
"""
69+
Wraps all Criterion instances in a combination with a TemporalCount (with interval_criterion).
70+
71+
:param expr: A single Criterion or an expression to be wrapped.
72+
:param interval_criterion: A Criterion or expression that defines the temporal interval.
73+
:return: A new expression where all Criterion instances are wrapped with a TemporalCount (with interval_criterion).
74+
"""
75+
temporal_combo_factory = lambda criterion: Presence(
76+
criterion=criterion, interval_criterion=interval_criterion
77+
)
78+
79+
new_combo = cast(
80+
logic.TemporalMinCount,
81+
_wrap_criteria_with_factory(expr, temporal_combo_factory),
82+
)
83+
84+
return new_combo

execution_engine/builder.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
from execution_engine.converter.goal.ventilator_management import (
2929
VentilatorManagementGoal,
3030
)
31-
from execution_engine.converter.time_from_event.abstract import TemporalIndicator
31+
from execution_engine.converter.relative_time.abstract import RelativeTime
32+
from execution_engine.converter.time_from_event.abstract import TimeFromEvent
3233

3334
if TYPE_CHECKING:
3435
from execution_engine.execution_engine import ExecutionEngine
@@ -42,7 +43,8 @@ class CriterionConverterType(TypedDict):
4243
characteristic: list[type[CriterionConverter]]
4344
action: list[type[CriterionConverter]]
4445
goal: list[type[CriterionConverter]]
45-
time_from_event: list[type[TemporalIndicator]]
46+
time_from_event: list[type[TimeFromEvent]]
47+
relative_time: list[type[RelativeTime]]
4648

4749

4850
_default_converters: CriterionConverterType = {
@@ -63,6 +65,7 @@ class CriterionConverterType(TypedDict):
6365
],
6466
"goal": [LaboratoryValueGoal, VentilatorManagementGoal, AssessmentScaleGoal],
6567
"time_from_event": [],
68+
"relative_time": [],
6669
}
6770

6871

@@ -76,6 +79,7 @@ def default_execution_engine_builder() -> "ExecutionEngineBuilder":
7679
builder.set_action_converters(_default_converters["action"])
7780
builder.set_goal_converters(_default_converters["goal"])
7881
builder.set_time_from_event_converters(_default_converters["time_from_event"])
82+
builder.set_relative_time_converters(_default_converters["relative_time"])
7983

8084
return builder
8185

@@ -92,7 +96,8 @@ def __init__(self) -> None:
9296
self.characteristic_converters: list[type[CriterionConverter]] = []
9397
self.action_converters: list[type[CriterionConverter]] = []
9498
self.goal_converters: list[type[CriterionConverter]] = []
95-
self.time_from_event_converters: list[type[TemporalIndicator]] = []
99+
self.time_from_event_converters: list[type[TimeFromEvent]] = []
100+
self.relative_time_converters: list[type[RelativeTime]] = []
96101

97102
def set_characteristic_converters(
98103
self, converters: list[type[CriterionConverter]]
@@ -128,7 +133,7 @@ def set_goal_converters(
128133
return self
129134

130135
def set_time_from_event_converters(
131-
self, converters: list[type[TemporalIndicator]]
136+
self, converters: list[type[TimeFromEvent]]
132137
) -> "ExecutionEngineBuilder":
133138
"""
134139
Sets (overwrites) the time from event converters for this builder.
@@ -140,6 +145,19 @@ def set_time_from_event_converters(
140145

141146
return self
142147

148+
def set_relative_time_converters(
149+
self, converters: list[type[RelativeTime]]
150+
) -> "ExecutionEngineBuilder":
151+
"""
152+
Sets (overwrites) the time from event converters for this builder.
153+
"""
154+
self.relative_time_converters.clear()
155+
156+
for converter_type in converters:
157+
self.append_relative_time_converter(converter_type)
158+
159+
return self
160+
143161
def append_characteristic_converter(
144162
self, converter_type: type[CriterionConverter]
145163
) -> "ExecutionEngineBuilder":
@@ -207,27 +225,49 @@ def prepend_goal_converter(
207225
return self
208226

209227
def append_time_from_event_converter(
210-
self, converter_type: type[TemporalIndicator]
228+
self, converter_type: type[TimeFromEvent]
211229
) -> "ExecutionEngineBuilder":
212230
"""
213231
Appends a single time_from_event converter at the end of the list.
214232
"""
215-
if not issubclass(converter_type, TemporalIndicator):
233+
if not issubclass(converter_type, TimeFromEvent):
216234
raise ValueError(f"Invalid TimeFromEvent converter type: {converter_type}")
217235
self.time_from_event_converters.append(converter_type)
218236
return self
219237

220238
def prepend_time_from_event_converter(
221-
self, converter_type: type[TemporalIndicator]
239+
self, converter_type: type[TimeFromEvent]
222240
) -> "ExecutionEngineBuilder":
223241
"""
224242
Inserts a single time_from_event converter at the front of the list.
225243
"""
226-
if not issubclass(converter_type, TemporalIndicator):
244+
if not issubclass(converter_type, TimeFromEvent):
227245
raise ValueError(f"Invalid TimeFromEvent converter type: {converter_type}")
228246
self.time_from_event_converters.insert(0, converter_type)
229247
return self
230248

249+
def append_relative_time_converter(
250+
self, converter_type: type[RelativeTime]
251+
) -> "ExecutionEngineBuilder":
252+
"""
253+
Appends a single relative_time converter at the end of the list.
254+
"""
255+
if not issubclass(converter_type, RelativeTime):
256+
raise ValueError(f"Invalid TimeFromEvent converter type: {converter_type}")
257+
self.relative_time_converters.append(converter_type)
258+
return self
259+
260+
def prepend_relative_time_converter(
261+
self, converter_type: type[RelativeTime]
262+
) -> "ExecutionEngineBuilder":
263+
"""
264+
Inserts a single relative_time converter at the front of the list.
265+
"""
266+
if not issubclass(converter_type, RelativeTime):
267+
raise ValueError(f"Invalid TimeFromEvent converter type: {converter_type}")
268+
self.relative_time_converters.insert(0, converter_type)
269+
return self
270+
231271
def build(self, verbose: bool = False) -> "ExecutionEngine":
232272
"""
233273
Builds an ExecutionEngine with the specified converters.

execution_engine/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
EXT_DOSAGE_CONDITION = "https://www.netzwerk-universitaetsmedizin.de/fhir/cpg-on-ebm-on-fhir/StructureDefinition/ext-dosage-condition"
2727
EXT_ACTION_COMBINATION_METHOD = "https://www.netzwerk-universitaetsmedizin.de/fhir/cpg-on-ebm-on-fhir/StructureDefinition/ext-action-combination-method"
2828
EXT_CPG_PARTOF = "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-partOf"
29+
EXT_RELATIVE_TIME = "https://www.netzwerk-universitaetsmedizin.de/fhir/cpg-on-ebm-on-fhir/StructureDefinition/relative-time"
2930

3031
CS_ACTION_COMBINATION_METHOD = "https://www.netzwerk-universitaetsmedizin.de/fhir/cpg-on-ebm-on-fhir/CodeSystem/cs-action-combination-method"
3132

execution_engine/converter/action/abstract.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
from fhir.resources.timing import Timing as FHIRTiming
55

6+
from execution_engine import constants
67
from execution_engine.converter.criterion import CriterionConverter, parse_code
78
from execution_engine.converter.goal.abstract import Goal
89
from execution_engine.fhir.recommendation import RecommendationPlan
9-
from execution_engine.fhir.util import get_coding
10+
from execution_engine.fhir.util import get_coding, get_extensions
1011
from execution_engine.omop.criterion.abstract import Criterion
1112
from execution_engine.omop.vocabulary import AbstractVocabulary
1213
from execution_engine.util import AbstractPrivateMethods, logic
@@ -136,6 +137,14 @@ def process_timing(cls, timing: FHIRTiming) -> Timing:
136137
if rep.offset is not None:
137138
raise NotImplementedError("offset has not been implemented")
138139

140+
relative_time = get_extensions(timing, constants.EXT_RELATIVE_TIME)
141+
142+
if relative_time:
143+
raise NotImplementedError(
144+
"RelativeTime processing within AbstractAction not implemented - "
145+
"should be performed in the parser"
146+
)
147+
139148
return Timing(
140149
count=count, duration=duration, frequency=frequency, interval=interval
141150
)

execution_engine/converter/action/procedure.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from execution_engine.fhir.recommendation import RecommendationPlan
44
from execution_engine.omop.concepts import Concept
55
from execution_engine.omop.criterion.abstract import Criterion
6+
from execution_engine.omop.criterion.condition_occurrence import ConditionOccurrence
7+
from execution_engine.omop.criterion.device_exposure import DeviceExposure
68
from execution_engine.omop.criterion.measurement import Measurement
79
from execution_engine.omop.criterion.observation import Observation
810
from execution_engine.omop.criterion.procedure_occurrence import ProcedureOccurrence
@@ -15,7 +17,7 @@ class ProcedureAction(AbstractAction):
1517
"""
1618
An ProcedureAction is an action that describes a procedure to be performed
1719
18-
This action tests whether the procedure has been performed by determining whether it is
20+
This action tests whether the procedure has been performed by determining whether it
1921
is present in the respective OMOP CDM table.
2022
"""
2123

@@ -82,6 +84,18 @@ def _to_expression(self) -> logic.Symbol:
8284
value_required=False,
8385
timing=self._timing,
8486
)
87+
case "Device":
88+
criterion = DeviceExposure(
89+
concept=self._code,
90+
value_required=False,
91+
timing=self._timing,
92+
)
93+
case "Condition":
94+
criterion = ConditionOccurrence(
95+
concept=self._code,
96+
value_required=False,
97+
timing=self._timing,
98+
)
8599
case _:
86100
raise ValueError(
87101
f"Concept domain {self._code.domain_id} is not supported for {self.__class__.__name__}]"

execution_engine/converter/parser/base.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from abc import ABC, abstractmethod
22
from typing import Callable, Type
33

4-
from fhir.resources.evidencevariable import EvidenceVariable
4+
from fhir.resources.evidencevariable import (
5+
EvidenceVariable,
6+
EvidenceVariableCharacteristic,
7+
EvidenceVariableCharacteristicTimeFromEvent,
8+
)
9+
from fhir.resources.extension import Extension
510
from fhir.resources.plandefinition import PlanDefinition, PlanDefinitionAction
611

712
from execution_engine import fhir
@@ -19,11 +24,13 @@ def __init__(
1924
action_converters: CriterionConverterFactory,
2025
goal_converters: CriterionConverterFactory,
2126
time_from_event_converters: TemporalIndicatorConverterFactory,
27+
relative_time_converters: TemporalIndicatorConverterFactory,
2228
):
2329
self.characteristics_converters = characteristic_converters
2430
self.action_converters = action_converters
2531
self.goal_converters = goal_converters
2632
self.time_from_event_converters = time_from_event_converters
33+
self.relative_time_converters = relative_time_converters
2734

2835
@abstractmethod
2936
def parse_characteristics(self, ev: EvidenceVariable) -> logic.BooleanFunction:
@@ -53,3 +60,31 @@ def parse_action_combination_method(
5360
combination method in form of a logical expression.
5461
"""
5562
raise NotImplementedError()
63+
64+
def parse_time_from_event(
65+
self,
66+
tfes: list[EvidenceVariableCharacteristicTimeFromEvent],
67+
) -> list[logic.BaseExpr]:
68+
"""
69+
Parses `timeFromEvent` elements and converts them into interval-based logical criteria.
70+
"""
71+
raise NotImplementedError()
72+
73+
@abstractmethod
74+
def parse_relative_time(
75+
self,
76+
relative_time: list[Extension],
77+
) -> list[logic.BaseExpr]:
78+
"""
79+
Parses `extension[relativeTime]` elements and converts them into interval-based logical criteria.
80+
"""
81+
raise NotImplementedError()
82+
83+
def parse_timing(
84+
self, characteristic: EvidenceVariableCharacteristic, expr: logic.BaseExpr
85+
) -> logic.BaseExpr:
86+
"""
87+
Applies temporal constraints to a given criterion expression based on `timeFromEvent` and
88+
the relativeTime extension elements.
89+
"""
90+
raise NotImplementedError()

0 commit comments

Comments
 (0)