omop-constructs 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/PKG-INFO +2 -2
  2. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/pyproject.toml +2 -2
  3. omop_constructs-0.4.2/src/omop_constructs/alchemy/__init__.py +7 -0
  4. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/__init__.py +3 -1
  5. omop_constructs-0.4.2/src/omop_constructs/alchemy/episodes/consult_window_mv.py +57 -0
  6. omop_constructs-0.4.2/src/omop_constructs/alchemy/episodes/consult_window_query.py +261 -0
  7. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/treatment_summary_mv.py +2 -1
  8. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/__init__.py +5 -1
  9. omop_constructs-0.4.2/src/omop_constructs/alchemy/events/dx_linked_visit_mv.py +56 -0
  10. omop_constructs-0.4.2/src/omop_constructs/alchemy/events/visit_queries.py +89 -0
  11. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/modifier_joins.py +9 -7
  12. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/constructs.py +14 -2
  13. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/materialized.py +12 -0
  14. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/plan.py +9 -0
  15. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/registry.py +25 -1
  16. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/semantics/lookups.py +10 -1
  17. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/semantics/runtime_lookups.py +10 -1
  18. omop_constructs-0.4.2/src/omop_constructs/services/__init__.py +5 -0
  19. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/typing.py +10 -1
  20. omop_constructs-0.4.0/src/omop_constructs/alchemy/__init__.py +0 -0
  21. omop_constructs-0.4.0/src/omop_constructs/services/__init__.py +0 -0
  22. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/README.md +0 -0
  23. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/__init__.py +0 -0
  24. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/adapters.py +0 -0
  25. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/concepts/__init__.py +0 -0
  26. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/concepts/concept_mappers.py +0 -0
  27. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/concepts/concept_standardisation.py +0 -0
  28. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/conditions/__init__.py +0 -0
  29. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/conditions/condition_joins.py +0 -0
  30. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/conditions/condition_mappers.py +0 -0
  31. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/demography/__init__.py +0 -0
  32. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/demography/demography_matview.py +0 -0
  33. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/demography/demography_queries.py +0 -0
  34. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/condition_episode_mv.py +0 -0
  35. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/condition_episode_objects.py +0 -0
  36. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/course_joins.py +0 -0
  37. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/course_mv.py +0 -0
  38. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/cycle_join.py +0 -0
  39. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/cycle_mv.py +0 -0
  40. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/episode_factories.py +0 -0
  41. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/episode_joins.py +0 -0
  42. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/fraction_joins.py +0 -0
  43. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/fraction_mv.py +0 -0
  44. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/modality_intent_join.py +0 -0
  45. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/modality_intent_mv.py +0 -0
  46. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/surgical_joins.py +0 -0
  47. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/surgical_procedure_mv.py +0 -0
  48. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/systemic_treatment_joins.py +0 -0
  49. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/systemic_treatment_mv.py +0 -0
  50. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/treatment_envelope_mv.py +0 -0
  51. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/treatment_envelope_query.py +0 -0
  52. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/episodes/treatment_summary_joins.py +0 -0
  53. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/dx_linked_event_mv.py +0 -0
  54. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/dx_linked_obs_mv.py +0 -0
  55. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/dx_linked_procedure_mv.py +0 -0
  56. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/event_factories.py +0 -0
  57. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/event_queries.py +0 -0
  58. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/observation_queries.py +0 -0
  59. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/events/procedure_queries.py +0 -0
  60. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/__init__.py +0 -0
  61. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/condition_modifier_join.py +0 -0
  62. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/condition_modifier_mv.py +0 -0
  63. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/modifier_factories.py +0 -0
  64. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/modifier_mappers.py +0 -0
  65. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/procedure_modifier_joins.py +0 -0
  66. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/modifiers/procedure_modifier_mv.py +0 -0
  67. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/alchemy/source_factories.py +0 -0
  68. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/__init__.py +0 -0
  69. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/base.py +0 -0
  70. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/ddl.py +0 -0
  71. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/core/errors.py +0 -0
  72. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/py.typed +0 -0
  73. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/semantics/__init__.py +0 -0
  74. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/semantics/adapters.py +0 -0
  75. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/services/concept.py +0 -0
  76. {omop_constructs-0.4.0 → omop_constructs-0.4.2}/src/omop_constructs/services/person.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: omop-constructs
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: For building complex constructs on top of the omop-alchemy library.
5
5
  Author: Georgie Kennedy
6
6
  Author-email: Georgie Kennedy <georgina.kennedy@unsw.edu.au>
7
7
  Requires-Dist: omop-alchemy>=0.6.1
8
- Requires-Dist: omop-semantics>=0.1.14
8
+ Requires-Dist: omop-semantics>=0.1.15
9
9
  Requires-Dist: psycopg2-binary>=2.9.11
10
10
  Requires-Dist: sqlalchemy>=2.0.45
11
11
  Requires-Dist: ipython>=9.8.0 ; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omop-constructs"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "For building complex constructs on top of the omop-alchemy library."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,7 +9,7 @@ authors = [
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "omop-alchemy>=0.6.1",
12
- "omop-semantics>=0.1.14",
12
+ "omop-semantics>=0.1.15",
13
13
  "psycopg2-binary>=2.9.11",
14
14
  "sqlalchemy>=2.0.45",
15
15
  ]
@@ -0,0 +1,7 @@
1
+ """
2
+ High-level construct families built on top of ``omop-alchemy`` models.
3
+
4
+ The subpackages under :mod:`omop_constructs.alchemy` expose the repository's
5
+ active analytical constructs, mostly as ORM-mapped materialized views plus the
6
+ query fragments used to build them.
7
+ """
@@ -8,6 +8,7 @@ from .fraction_mv import FractionMV
8
8
  from .treatment_summary_mv import ConditionTreatmentEpisode
9
9
  from .treatment_envelope_mv import TreatmentEnvelopeMV
10
10
  from .modality_intent_mv import TreatmentIntentMV, ConditionTreatmentIntentMV
11
+ from .consult_window_mv import ConsultWindowMV
11
12
 
12
13
  __all__ = [
13
14
  "OverarchingDiseaseEpisodeMV",
@@ -24,4 +25,5 @@ __all__ = [
24
25
  "TreatmentEnvelopeMV",
25
26
  "TreatmentIntentMV",
26
27
  "ConditionTreatmentIntentMV",
27
- ]
28
+ "ConsultWindowMV",
29
+ ]
@@ -0,0 +1,57 @@
1
+ import sqlalchemy as sa
2
+ import sqlalchemy.orm as so
3
+ from datetime import date
4
+ from typing import Optional
5
+
6
+ from orm_loader.helpers import Base
7
+
8
+ from ...core.constructs import register_construct
9
+ from ...core.materialized import MaterializedViewMixin
10
+ from ..events.dx_linked_obs_mv import DxObservationMV
11
+ from ..events.dx_linked_visit_mv import DxRelevantVisitMV
12
+ from .consult_window_query import consult_window
13
+ from .treatment_envelope_mv import TreatmentEnvelopeMV
14
+
15
+
16
+ @register_construct
17
+ class ConsultWindowMV(MaterializedViewMixin, Base):
18
+ """
19
+ Episode-of-care consult and referral window summary.
20
+
21
+ The materialized view exposes the earliest specialist contact and the
22
+ derived scalar windows currently used downstream for referral timing
23
+ analysis.
24
+ """
25
+ __mv_name__ = "consult_window_mv"
26
+ __mv_select__ = consult_window.select()
27
+ __mv_index__ = "episode_id"
28
+ __deps__ = (
29
+ DxObservationMV.__mv_name__,
30
+ DxRelevantVisitMV.__mv_name__,
31
+ TreatmentEnvelopeMV.__mv_name__,
32
+ )
33
+ __tablename__ = __mv_name__
34
+ __table_args__ = {"extend_existing": True}
35
+
36
+ mv_id: so.Mapped[int] = so.mapped_column(primary_key=True)
37
+ person_id: so.Mapped[int] = so.mapped_column(sa.Integer)
38
+ episode_id: so.Mapped[int] = so.mapped_column(sa.Integer)
39
+ episode_start_date: so.Mapped[date] = so.mapped_column(sa.Date)
40
+ episode_concept_id: so.Mapped[int] = so.mapped_column(sa.Integer)
41
+ initial_gp_referral: so.Mapped[Optional[date]] = so.mapped_column(
42
+ sa.Date, nullable=True
43
+ )
44
+ first_specialist: so.Mapped[Optional[date]] = so.mapped_column(sa.Date, nullable=True)
45
+ first_pall_care: so.Mapped[Optional[date]] = so.mapped_column(sa.Date, nullable=True)
46
+ first_pall_care_or_treatment: so.Mapped[Optional[date]] = so.mapped_column(
47
+ sa.Date, nullable=True
48
+ )
49
+ earliest_treatment: so.Mapped[Optional[date]] = so.mapped_column(
50
+ sa.Date, nullable=True
51
+ )
52
+ referral_to_specialist: so.Mapped[Optional[float]] = so.mapped_column(
53
+ sa.Float, nullable=True
54
+ )
55
+ referral_to_tx: so.Mapped[Optional[float]] = so.mapped_column(
56
+ sa.Float, nullable=True
57
+ )
@@ -0,0 +1,261 @@
1
+ """
2
+ Episode-level consult and referral window query fragments.
3
+
4
+ The current implementation combines episode-of-care anchors, diagnosis-linked
5
+ consult observations, ranked specialist visits, and the treatment envelope to
6
+ derive referral-to-specialist and referral-to-treatment windows.
7
+ """
8
+
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.sql import ColumnElement
11
+
12
+ from omop_alchemy.cdm.model import Episode
13
+ from omop_semantics.runtime.default_valuesets import runtime # type: ignore
14
+
15
+ from ..events.dx_linked_obs_mv import DxObservationMV
16
+ from ..events.dx_linked_visit_mv import DxRelevantVisitMV
17
+ from .treatment_envelope_mv import TreatmentEnvelopeMV
18
+
19
+
20
+ def days_between(
21
+ end_date: ColumnElement,
22
+ start_date: ColumnElement,
23
+ ) -> ColumnElement:
24
+ """
25
+ Return the interval between two date-like expressions in days.
26
+ """
27
+ return sa.func.extract(
28
+ "epoch",
29
+ sa.cast(end_date, sa.DateTime) - sa.cast(start_date, sa.DateTime),
30
+ ) / 86400.0
31
+
32
+
33
+ episode_of_care = (
34
+ sa.select(
35
+ Episode.person_id,
36
+ Episode.episode_id,
37
+ Episode.episode_start_date,
38
+ Episode.episode_concept_id,
39
+ )
40
+ .where(
41
+ Episode.episode_concept_id
42
+ == runtime.types.disease_episode_types.episode_of_care # type: ignore[attr-defined]
43
+ )
44
+ .subquery(name="episode_of_care")
45
+ )
46
+
47
+ episode_of_care_obs = (
48
+ sa.select(*DxObservationMV.__table__.c)
49
+ .where(
50
+ DxObservationMV.episode_concept_id
51
+ == runtime.types.disease_episode_types.episode_of_care # type: ignore[attr-defined]
52
+ )
53
+ .subquery(name="episode_of_care_obs")
54
+ )
55
+
56
+ first_specialist = (
57
+ sa.select(
58
+ episode_of_care_obs.c.person_id,
59
+ episode_of_care_obs.c.episode_id,
60
+ sa.func.min(episode_of_care_obs.c.event_date).label("first_specialist_consult"),
61
+ sa.func.max(episode_of_care_obs.c.event_date).label("last_specialist_consult"),
62
+ )
63
+ .where(
64
+ episode_of_care_obs.c.event_concept_id.in_(
65
+ [
66
+ runtime.cancer_procedures.cancer_consult_types.medonc, # type: ignore[attr-defined]
67
+ runtime.cancer_procedures.cancer_consult_types.clinonc, # type: ignore[attr-defined]
68
+ ]
69
+ )
70
+ )
71
+ .group_by(
72
+ episode_of_care_obs.c.person_id,
73
+ episode_of_care_obs.c.episode_id,
74
+ )
75
+ .subquery(name="first_specialist")
76
+ )
77
+
78
+ gp_referral = (
79
+ sa.select(
80
+ episode_of_care_obs.c.person_id,
81
+ episode_of_care_obs.c.episode_id,
82
+ sa.func.min(episode_of_care_obs.c.event_date).label("initial_gp_referral"),
83
+ )
84
+ .where(
85
+ episode_of_care_obs.c.event_concept_id
86
+ == runtime.cancer_procedures.cancer_consult_types.oncology_referral # type: ignore[attr-defined]
87
+ )
88
+ .group_by(
89
+ episode_of_care_obs.c.person_id,
90
+ episode_of_care_obs.c.episode_id,
91
+ )
92
+ .subquery(name="gp_referral")
93
+ )
94
+
95
+ pall_care_referral = (
96
+ sa.select(
97
+ episode_of_care_obs.c.person_id,
98
+ episode_of_care_obs.c.episode_id,
99
+ sa.func.min(episode_of_care_obs.c.event_date).label("first_pall_care_referral"),
100
+ )
101
+ .where(
102
+ episode_of_care_obs.c.event_concept_id
103
+ == runtime.cancer_procedures.cancer_consult_types.pall_care_referral # type: ignore[attr-defined]
104
+ )
105
+ .group_by(
106
+ episode_of_care_obs.c.person_id,
107
+ episode_of_care_obs.c.episode_id,
108
+ )
109
+ .subquery(name="pall_care_referral")
110
+ )
111
+
112
+ specialist_visit = (
113
+ sa.select(
114
+ DxRelevantVisitMV.person_id,
115
+ DxRelevantVisitMV.episode_id,
116
+ sa.func.min(DxRelevantVisitMV.visit_start_date).label("first_specialist_visit"),
117
+ )
118
+ .where(
119
+ DxRelevantVisitMV.provider_specialty_concept_id.in_(
120
+ [
121
+ runtime.cancer_procedures.encounter_provider_specialty.radonc, # type: ignore[attr-defined]
122
+ runtime.cancer_procedures.encounter_provider_specialty.medonc, # type: ignore[attr-defined]
123
+ runtime.cancer_procedures.encounter_provider_specialty.haematologist, # type: ignore[attr-defined]
124
+ ]
125
+ )
126
+ )
127
+ .group_by(
128
+ DxRelevantVisitMV.person_id,
129
+ DxRelevantVisitMV.episode_id,
130
+ )
131
+ .subquery(name="specialist_visit")
132
+ )
133
+
134
+ pall_care_visit = (
135
+ sa.select(
136
+ DxRelevantVisitMV.person_id,
137
+ DxRelevantVisitMV.episode_id,
138
+ sa.func.min(DxRelevantVisitMV.visit_start_date).label("first_pall_care_visit"),
139
+ )
140
+ .where(
141
+ DxRelevantVisitMV.provider_specialty_concept_id
142
+ == runtime.cancer_procedures.encounter_provider_specialty.pall_care # type: ignore[attr-defined]
143
+ )
144
+ .group_by(
145
+ DxRelevantVisitMV.person_id,
146
+ DxRelevantVisitMV.episode_id,
147
+ )
148
+ .subquery(name="pall_care_visit")
149
+ )
150
+
151
+ specialist_trajectory = (
152
+ sa.select(
153
+ episode_of_care.c.person_id,
154
+ episode_of_care.c.episode_id,
155
+ episode_of_care.c.episode_start_date,
156
+ episode_of_care.c.episode_concept_id,
157
+ first_specialist.c.first_specialist_consult,
158
+ first_specialist.c.last_specialist_consult,
159
+ specialist_visit.c.first_specialist_visit,
160
+ gp_referral.c.initial_gp_referral,
161
+ pall_care_referral.c.first_pall_care_referral,
162
+ pall_care_visit.c.first_pall_care_visit,
163
+ )
164
+ .join(
165
+ first_specialist,
166
+ first_specialist.c.episode_id == episode_of_care.c.episode_id,
167
+ isouter=True,
168
+ )
169
+ .join(
170
+ gp_referral,
171
+ gp_referral.c.episode_id == episode_of_care.c.episode_id,
172
+ isouter=True,
173
+ )
174
+ .join(
175
+ pall_care_referral,
176
+ pall_care_referral.c.episode_id == episode_of_care.c.episode_id,
177
+ isouter=True,
178
+ )
179
+ .join(
180
+ specialist_visit,
181
+ specialist_visit.c.episode_id == episode_of_care.c.episode_id,
182
+ isouter=True,
183
+ )
184
+ .join(
185
+ pall_care_visit,
186
+ pall_care_visit.c.episode_id == episode_of_care.c.episode_id,
187
+ isouter=True,
188
+ )
189
+ .subquery(name="specialist_trajectory")
190
+ )
191
+
192
+ consult_window = (
193
+ sa.select(
194
+ sa.func.row_number().over().label("mv_id"),
195
+ specialist_trajectory.c.person_id,
196
+ specialist_trajectory.c.episode_id,
197
+ specialist_trajectory.c.episode_start_date,
198
+ specialist_trajectory.c.initial_gp_referral,
199
+ specialist_trajectory.c.episode_concept_id,
200
+ sa.func.least(
201
+ specialist_trajectory.c.first_specialist_consult,
202
+ specialist_trajectory.c.first_specialist_visit,
203
+ ).label("first_specialist"),
204
+ sa.func.least(
205
+ specialist_trajectory.c.first_pall_care_referral,
206
+ specialist_trajectory.c.first_pall_care_visit,
207
+ ).label("first_pall_care"),
208
+ sa.func.least(
209
+ specialist_trajectory.c.first_pall_care_referral,
210
+ specialist_trajectory.c.first_pall_care_visit,
211
+ TreatmentEnvelopeMV.earliest_treatment,
212
+ ).label("first_pall_care_or_treatment"),
213
+ TreatmentEnvelopeMV.earliest_treatment,
214
+ sa.case(
215
+ (
216
+ sa.and_(
217
+ specialist_trajectory.c.initial_gp_referral.is_not(None),
218
+ sa.func.least(
219
+ specialist_trajectory.c.first_specialist_consult,
220
+ specialist_trajectory.c.first_specialist_visit,
221
+ ).is_not(None),
222
+ ),
223
+ days_between(
224
+ sa.func.least(
225
+ specialist_trajectory.c.first_specialist_consult,
226
+ specialist_trajectory.c.first_specialist_visit,
227
+ ),
228
+ specialist_trajectory.c.initial_gp_referral,
229
+ ),
230
+ ),
231
+ else_=None,
232
+ ).label("referral_to_specialist"),
233
+ sa.case(
234
+ (
235
+ sa.and_(
236
+ specialist_trajectory.c.initial_gp_referral.is_not(None),
237
+ sa.func.least(
238
+ specialist_trajectory.c.first_pall_care_referral,
239
+ specialist_trajectory.c.first_pall_care_visit,
240
+ TreatmentEnvelopeMV.earliest_treatment,
241
+ ).is_not(None),
242
+ ),
243
+ days_between(
244
+ sa.func.least(
245
+ specialist_trajectory.c.first_pall_care_referral,
246
+ specialist_trajectory.c.first_pall_care_visit,
247
+ TreatmentEnvelopeMV.earliest_treatment,
248
+ ),
249
+ specialist_trajectory.c.initial_gp_referral,
250
+ ),
251
+ ),
252
+ else_=None,
253
+ ).label("referral_to_tx"),
254
+ )
255
+ .join(
256
+ TreatmentEnvelopeMV,
257
+ TreatmentEnvelopeMV.condition_episode == specialist_trajectory.c.episode_id,
258
+ isouter=True,
259
+ )
260
+ .subquery(name="consult_window")
261
+ )
@@ -4,6 +4,7 @@ from orm_loader.helpers import Base
4
4
  from datetime import date
5
5
  from typing import Optional
6
6
  from .treatment_summary_joins import condition_treatment_join
7
+ from .treatment_envelope_mv import TreatmentEnvelopeMV
7
8
  from .course_mv import RTCourseMV
8
9
  from .systemic_treatment_mv import SACTRegimenMV
9
10
  from ...core.materialized import MaterializedViewMixin
@@ -44,4 +45,4 @@ class ConditionTreatmentEpisode(
44
45
  sact_intent_concept_id: so.Mapped[int] = so.mapped_column(sa.Integer)
45
46
  sact_intent_concept: so.Mapped[int] = so.mapped_column(sa.Integer)
46
47
  exposure_count: so.Mapped[int] = so.mapped_column(sa.Integer)
47
- regimen_count: so.Mapped[int] = so.mapped_column(sa.Integer)
48
+ regimen_count: so.Mapped[int] = so.mapped_column(sa.Integer)
@@ -18,6 +18,9 @@ from .dx_linked_procedure_mv import (
18
18
  from .dx_linked_obs_mv import (
19
19
  DxObservationMV,
20
20
  )
21
+ from .dx_linked_visit_mv import (
22
+ DxRelevantVisitMV,
23
+ )
21
24
 
22
25
  __all__ = [
23
26
  "WeightDxMV",
@@ -33,4 +36,5 @@ __all__ = [
33
36
  "DxMeasurementMV",
34
37
  "DxProcedureMV",
35
38
  "DxObservationMV",
36
- ]
39
+ "DxRelevantVisitMV",
40
+ ]
@@ -0,0 +1,56 @@
1
+ import sqlalchemy as sa
2
+ import sqlalchemy.orm as so
3
+ from datetime import date, datetime
4
+ from typing import Optional
5
+
6
+ from orm_loader.helpers import Base
7
+
8
+ from ...core.constructs import register_construct
9
+ from ...core.materialized import MaterializedViewMixin
10
+ from .visit_queries import dx_relevant_visits
11
+
12
+
13
+ class ConditionEpisodeVisitCols:
14
+ """
15
+ Shared mapped columns for diagnosis-relevant visit materialized views.
16
+ """
17
+ __table_args__ = {"extend_existing": True}
18
+
19
+ mv_id: so.Mapped[int] = so.mapped_column(primary_key=True)
20
+
21
+ person_id: so.Mapped[int] = so.mapped_column(sa.Integer)
22
+ visit_occurrence_id: so.Mapped[int] = so.mapped_column(sa.Integer)
23
+ visit_start_date: so.Mapped[date] = so.mapped_column(sa.Date)
24
+ visit_start_datetime: so.Mapped[Optional[datetime]] = so.mapped_column(
25
+ sa.DateTime, nullable=True
26
+ )
27
+
28
+ provider_id: so.Mapped[int] = so.mapped_column(sa.Integer)
29
+ provider_specialty: so.Mapped[Optional[str]] = so.mapped_column(
30
+ sa.String, nullable=True
31
+ )
32
+ provider_specialty_concept_id: so.Mapped[Optional[int]] = so.mapped_column(
33
+ sa.Integer, nullable=True
34
+ )
35
+
36
+ episode_id: so.Mapped[int] = so.mapped_column(sa.Integer)
37
+ episode_start_date: so.Mapped[date] = so.mapped_column(sa.Date)
38
+
39
+ episode_prior: so.Mapped[int] = so.mapped_column(sa.Integer)
40
+ diff_days: so.Mapped[float] = so.mapped_column(sa.Float)
41
+ rank: so.Mapped[int] = so.mapped_column(sa.Integer)
42
+
43
+
44
+ @register_construct
45
+ class DxRelevantVisitMV(ConditionEpisodeVisitCols, MaterializedViewMixin, Base):
46
+ """
47
+ Provider-specialty visits linked to diagnosis episodes.
48
+
49
+ This view uses ranked episode assignment logic tailored to specialist visit
50
+ analysis rather than the generic event factory time-window attachment path.
51
+ """
52
+ __mv_name__ = "dx_visit_mv"
53
+ __mv_select__ = dx_relevant_visits.select()
54
+ __mv_index__ = "person_id"
55
+ __deps__ = ()
56
+ __tablename__ = __mv_name__
@@ -0,0 +1,89 @@
1
+ """
2
+ Query fragments for linking visit occurrences to disease episodes by specialty.
3
+
4
+ These queries support specialist consult-style measures where visit/provider
5
+ specialty is part of the derivation and generic event-attachment helpers are not
6
+ quite expressive enough.
7
+ """
8
+
9
+ import sqlalchemy as sa
10
+ import sqlalchemy.orm as so
11
+
12
+ from omop_alchemy.cdm.model import Concept, Episode, Provider, Visit_Occurrence
13
+ from omop_semantics.runtime.default_valuesets import runtime # type: ignore
14
+
15
+
16
+ provider_concept = so.aliased(Concept, name="provider_concept")
17
+
18
+ episode_of_care = (
19
+ sa.select(
20
+ Episode.person_id,
21
+ Episode.episode_id,
22
+ Episode.episode_start_date,
23
+ )
24
+ .where(
25
+ Episode.episode_concept_id
26
+ == runtime.types.disease_episode_types.episode_of_care # type: ignore[attr-defined]
27
+ )
28
+ .subquery(name="episode_of_care")
29
+ )
30
+
31
+ visit_start = sa.func.coalesce(
32
+ Visit_Occurrence.visit_start_datetime,
33
+ sa.cast(Visit_Occurrence.visit_start_date, sa.DateTime),
34
+ )
35
+ episode_start = sa.cast(episode_of_care.c.episode_start_date, sa.DateTime)
36
+
37
+ diff_days = sa.func.extract("epoch", visit_start - episode_start) / 86400.0
38
+
39
+ episode_start_prior = sa.case(
40
+ (
41
+ sa.func.abs(diff_days) < 180,
42
+ 1,
43
+ ),
44
+ (
45
+ visit_start > episode_start,
46
+ 2,
47
+ ),
48
+ else_=3,
49
+ )
50
+
51
+ visits_by_specialty = (
52
+ sa.select(
53
+ Visit_Occurrence.person_id,
54
+ Visit_Occurrence.visit_occurrence_id,
55
+ Visit_Occurrence.visit_start_date,
56
+ Visit_Occurrence.visit_start_datetime,
57
+ Provider.provider_id,
58
+ provider_concept.concept_name.label("provider_specialty"),
59
+ provider_concept.concept_id.label("provider_specialty_concept_id"),
60
+ episode_of_care.c.episode_id,
61
+ episode_of_care.c.episode_start_date,
62
+ episode_start_prior.label("episode_prior"),
63
+ diff_days.label("diff_days"),
64
+ sa.func.row_number()
65
+ .over(
66
+ partition_by=Visit_Occurrence.visit_occurrence_id,
67
+ order_by=[episode_start_prior, diff_days],
68
+ )
69
+ .label("rank"),
70
+ )
71
+ .join(episode_of_care, episode_of_care.c.person_id == Visit_Occurrence.person_id)
72
+ .join(Provider, Visit_Occurrence.provider_id == Provider.provider_id)
73
+ .join(provider_concept, provider_concept.concept_id == Provider.specialty_concept_id)
74
+ .subquery(name="visits_by_specialty")
75
+ )
76
+
77
+ dx_relevant_visits = (
78
+ sa.select(
79
+ sa.func.row_number().over().label("mv_id"),
80
+ *visits_by_specialty.c,
81
+ )
82
+ .where(
83
+ sa.or_(
84
+ visits_by_specialty.c.episode_prior == 1,
85
+ visits_by_specialty.c.rank == 1,
86
+ )
87
+ )
88
+ .subquery(name="dx_relevant_visits")
89
+ )
@@ -1,14 +1,16 @@
1
+ """
2
+ Resolver-backed query fragments for staging and condition modifier selection.
3
+
4
+ Importing this module is not a pure no-op. At module import time it resolves the
5
+ configured semantics-backed resolver sets and uses them to build the stage and
6
+ modifier query fragments that feed the modifier materialized views.
7
+ """
8
+
1
9
  from omop_constructs.semantics import registry
2
10
  from omop_alchemy.cdm.model.clinical import Measurement
3
11
  from omop_semantics.runtime.default_valuesets import runtime
4
12
  from .modifier_factories import get_query_per_stage_type, get_eav_modifier_query, earliest_modifier, get_direct_modifier_query
5
- """
6
- These calls have side effects of building the resolvers and querying the database to
7
- cache the required concepts in memory, but this is necessary to create the correct
8
- subqueries for staging modifiers.
9
13
 
10
- TODO: we may want to refactor to separate out the resolver construction from the concept retrieval?
11
- """
12
14
  t_stage_select = get_query_per_stage_type(registry['tnm_t_stage'].all_concepts, name="t_stage")
13
15
  n_stage_select = get_query_per_stage_type(registry['tnm_n_stage'].all_concepts, name="n_stage")
14
16
  m_stage_select = get_query_per_stage_type(registry['tnm_m_stage'].all_concepts, name="m_stage")
@@ -50,4 +52,4 @@ all_stage_select = get_direct_modifier_query(
50
52
  )
51
53
  ),
52
54
  name="all_stage_modifiers"
53
- )
55
+ )
@@ -9,7 +9,11 @@ _CONSTRUCTS: Dict[str, Type[SupportsMaterializedView]] = {}
9
9
 
10
10
  def register_construct(cls: T) -> T:
11
11
  """
12
- Decorator to register a materialized view / construct class.
12
+ Register a materialized view-backed construct class.
13
+
14
+ Registration is import-driven: any module that defines construct classes and
15
+ imports this decorator will add those classes to the in-process registry as
16
+ soon as the module is imported.
13
17
  """
14
18
  name = getattr(cls, "__mv_name__", None)
15
19
  if not name:
@@ -24,4 +28,12 @@ def register_construct(cls: T) -> T:
24
28
  return cls
25
29
 
26
30
  def get_construct_registry() -> ConstructRegistry:
27
- return ConstructRegistry(_CONSTRUCTS.values())
31
+ """
32
+ Return a registry view over all construct classes imported so far.
33
+
34
+ Notes
35
+ -----
36
+ The returned registry reflects the current Python process. If a construct
37
+ module has not yet been imported, its classes will not appear here.
38
+ """
39
+ return ConstructRegistry(_CONSTRUCTS.values())
@@ -18,6 +18,9 @@ class MaterializedViewMixin:
18
18
 
19
19
  @classmethod
20
20
  def create_mv(cls, bind, *, with_data: bool = True) -> None:
21
+ """
22
+ Create the backing materialized view for this mapped class.
23
+ """
21
24
  ddl = CreateMaterializedView(cls.__mv_name__, cls.__mv_select__, with_data=with_data)
22
25
  bind.execute(ddl)
23
26
  if cls.__mv_index__ is not None:
@@ -25,6 +28,9 @@ class MaterializedViewMixin:
25
28
 
26
29
  @classmethod
27
30
  def refresh_mv(cls, bind, *, concurrently: bool = False):
31
+ """
32
+ Refresh the backing materialized view.
33
+ """
28
34
  suffix = " CONCURRENTLY" if concurrently else ""
29
35
  bind.execute(
30
36
  sa.text(f"REFRESH MATERIALIZED VIEW{suffix} {cls.__mv_name__}")
@@ -32,11 +38,17 @@ class MaterializedViewMixin:
32
38
 
33
39
  @classmethod
34
40
  def drop_mv(cls, bind, *, cascade: bool = False):
41
+ """
42
+ Drop the backing materialized view.
43
+ """
35
44
  ddl = DropMaterializedView(cls.__mv_name__, cascade=cascade)
36
45
  bind.execute(ddl)
37
46
 
38
47
  @classmethod
39
48
  def create_index(cls, bind, index_colname: str):
49
+ """
50
+ Create the configured helper index for the materialized view if needed.
51
+ """
40
52
  bind.execute(sa.text(
41
53
  f"CREATE INDEX IF NOT EXISTS {cls.__mv_name__}_{index_colname}_idx "
42
54
  f"ON {cls.__mv_name__} ({index_colname})"
@@ -7,11 +7,20 @@ from .errors import DependencyCycleError
7
7
 
8
8
  @dataclass(frozen=True)
9
9
  class ConstructNode:
10
+ """
11
+ Lightweight dependency node used when building construct execution plans.
12
+ """
10
13
  name: str
11
14
  deps: tuple[str, ...] = ()
12
15
  kind: str = "materialized_view"
13
16
 
14
17
  def topo_sort(nodes: list[ConstructNode]) -> list[ConstructNode]:
18
+ """
19
+ Topologically sort construct nodes by their declared dependencies.
20
+
21
+ Dependencies that point outside the provided node set are treated as
22
+ external and ignored for ordering purposes.
23
+ """
15
24
  by_name = {n.name: n for n in nodes}
16
25
  indeg = {n.name: 0 for n in nodes}
17
26
  adj = defaultdict(list)
@@ -9,12 +9,18 @@ from ..typing import SupportsMaterializedView
9
9
 
10
10
  @dataclass(frozen=True)
11
11
  class ConstructPlanItem:
12
+ """
13
+ Serializable view of a construct's name, kind, and declared dependencies.
14
+ """
12
15
  name: str
13
16
  kind: str
14
17
  deps: tuple[str, ...]
15
18
 
16
19
 
17
20
  def materialized_view_exists(bind, name: str, schema: str = "public") -> bool:
21
+ """
22
+ Check whether a PostgreSQL materialized view already exists.
23
+ """
18
24
  sql = sa.text("""
19
25
  SELECT EXISTS (
20
26
  SELECT 1
@@ -34,15 +40,24 @@ class ConstructRegistry:
34
40
  _constructs: dict[str, Type[SupportsMaterializedView]]
35
41
 
36
42
  def __init__(self, constructs: Iterable[type[SupportsMaterializedView]]):
43
+ """
44
+ Build a registry over an iterable of construct classes.
45
+ """
37
46
  self._constructs = {c.__mv_name__: c for c in constructs}
38
47
 
39
48
  def __iter__(self) -> Iterator[type[SupportsMaterializedView]]:
40
49
  return iter(self._constructs.values())
41
50
 
42
51
  def get(self, name: str) -> type[SupportsMaterializedView]:
52
+ """
53
+ Return a registered construct class by materialized view name.
54
+ """
43
55
  return self._constructs[name]
44
56
 
45
57
  def plan(self) -> tuple[ConstructPlanItem, ...]:
58
+ """
59
+ Return constructs in dependency order.
60
+ """
46
61
  nodes = [
47
62
  ConstructNode(name=k, deps=v.__deps__, kind=getattr(v.__construct__, "kind", "materialized_view"))
48
63
  for k, v in self._constructs.items()
@@ -51,6 +66,9 @@ class ConstructRegistry:
51
66
  return tuple(ConstructPlanItem(n.name, n.kind, n.deps) for n in ordered)
52
67
 
53
68
  def create_all(self, bind, *, with_data: bool = True):
69
+ """
70
+ Create every registered materialized view in dependency order.
71
+ """
54
72
  for item in self.plan():
55
73
  cls = self._constructs[item.name]
56
74
  cls.create_mv(bind, with_data=with_data)
@@ -60,6 +78,9 @@ class ConstructRegistry:
60
78
  self._constructs[item.name].refresh_mv(bind, concurrently=concurrently)
61
79
 
62
80
  def drop_all(self, bind, *, cascade: bool = False) -> None:
81
+ """
82
+ Drop every registered materialized view in reverse dependency order.
83
+ """
63
84
  # drop reverse order
64
85
  for item in reversed(self.plan()):
65
86
  try:
@@ -221,6 +242,9 @@ class ConstructRegistry:
221
242
 
222
243
 
223
244
  def describe(self, full: bool = False) -> str:
245
+ """
246
+ Render a text description of the registry dependency graph.
247
+ """
224
248
  try:
225
249
  items = self.plan()
226
250
  except Exception:
@@ -274,4 +298,4 @@ class ConstructRegistry:
274
298
  last_root = i == len(roots) - 1
275
299
  walk(root, "", last_root)
276
300
 
277
- return "\n".join(lines)
301
+ return "\n".join(lines)
@@ -14,6 +14,9 @@ from typing import Callable
14
14
  # as a pre-processing step
15
15
 
16
16
  def get_concept_resolver_registry(engine: sa.engine.Engine) -> ConceptResolverRegistry:
17
+ """
18
+ Create a resolver registry bound to the supplied SQLAlchemy engine.
19
+ """
17
20
  return ConceptResolverRegistry(engine)
18
21
 
19
22
 
@@ -25,6 +28,9 @@ def build_parent_resolver(
25
28
  corrections: list[Callable] = [],
26
29
  unknown_concept_id: int = UNKNOWN["generic"].concept_id
27
30
  ) -> ConceptResolver:
31
+ """
32
+ Build a resolver from one or more OMOP parent concepts.
33
+ """
28
34
  return make_concept_resolver(
29
35
  session,
30
36
  name=resolver_name,
@@ -40,9 +46,12 @@ def build_stage_resolver(
40
46
  parent_list: list[int],
41
47
  stage_name: str
42
48
  ) -> ConceptResolver:
49
+ """
50
+ Build a staging resolver with the standard TNM correction pipeline.
51
+ """
43
52
  return build_parent_resolver(
44
53
  session,
45
54
  resolver_name=f"tnm_{stage_name}_stage",
46
55
  parent_list=parent_list,
47
56
  corrections=[strip_uicc, make_stage],
48
- )
57
+ )
@@ -54,16 +54,25 @@ DEFAULT_RESOLVER_BUILDERS = {
54
54
  }
55
55
 
56
56
  def get_registry_engine(env_path: str = __file__):
57
+ """
58
+ Build the engine used for runtime resolver lookup.
59
+
60
+ If ``ENGINE`` is not already defined in the environment, the function asks
61
+ ``omop-alchemy`` to load environment configuration relative to ``env_path``.
62
+ """
57
63
  if os.environ.get("ENGINE") is None:
58
64
  load_environment(env_path)
59
65
  engine_string = get_engine_name()
60
66
  return sa.create_engine(engine_string, future=True, echo=False)
61
67
 
62
68
  def get_runtime_resolvers(engine: sa.Engine) -> ConceptResolverRegistry:
69
+ """
70
+ Return a resolver registry populated with the default resolver builders.
71
+ """
63
72
  resolver_registry = get_concept_resolver_registry(engine)
64
73
 
65
74
  # Register all known resolvers up-front (lazy build)
66
75
  for name, builder in DEFAULT_RESOLVER_BUILDERS.items():
67
76
  resolver_registry.register(name, builder)
68
77
 
69
- return resolver_registry
78
+ return resolver_registry
@@ -0,0 +1,5 @@
1
+ """
2
+ Small helper services used alongside the construct layer.
3
+
4
+ These are thin convenience functions rather than registered construct classes.
5
+ """
@@ -4,6 +4,9 @@ from .core.plan import ConstructNode, topo_sort
4
4
 
5
5
  @runtime_checkable
6
6
  class SupportsMaterializedView(Protocol):
7
+ """
8
+ Protocol for ORM classes backed by materialized view definitions.
9
+ """
7
10
  __mv_name__: str
8
11
  __mv_select__: sa.sql.Select
9
12
  __deps__: tuple[str, ...]
@@ -19,6 +22,9 @@ class SupportsMaterializedView(Protocol):
19
22
 
20
23
  @runtime_checkable
21
24
  class SupportsConstructRegistry(Protocol):
25
+ """
26
+ Protocol for registry-like lifecycle managers.
27
+ """
22
28
  def create_all(self, bind: Any) -> None: ...
23
29
  def refresh_all(
24
30
  self,
@@ -31,12 +37,15 @@ class SupportsConstructRegistry(Protocol):
31
37
 
32
38
  @runtime_checkable
33
39
  class SupportsSelectable(Protocol):
40
+ """
41
+ Protocol for objects that can emit a SQLAlchemy ``Select``.
42
+ """
34
43
  def select(self) -> sa.sql.Select: ...
35
44
 
36
45
 
37
46
  @runtime_checkable
38
47
  class SupportsQueryFactory(Protocol):
39
48
  """
40
- Factory that produces SQLAlchemy Selectables
49
+ Factory that produces SQLAlchemy ``Select`` objects.
41
50
  """
42
51
  def build(self) -> sa.sql.Select: ...