oehrpy 0.1.0__py3-none-any.whl
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.
- oehrpy-0.1.0.dist-info/METADATA +362 -0
- oehrpy-0.1.0.dist-info/RECORD +18 -0
- oehrpy-0.1.0.dist-info/WHEEL +4 -0
- oehrpy-0.1.0.dist-info/licenses/LICENSE +21 -0
- openehr_sdk/__init__.py +49 -0
- openehr_sdk/aql/__init__.py +40 -0
- openehr_sdk/aql/builder.py +589 -0
- openehr_sdk/client/__init__.py +32 -0
- openehr_sdk/client/ehrbase.py +675 -0
- openehr_sdk/rm/__init__.py +17 -0
- openehr_sdk/rm/rm_types.py +1864 -0
- openehr_sdk/serialization/__init__.py +37 -0
- openehr_sdk/serialization/canonical.py +203 -0
- openehr_sdk/serialization/flat.py +372 -0
- openehr_sdk/templates/__init__.py +40 -0
- openehr_sdk/templates/builder_generator.py +421 -0
- openehr_sdk/templates/builders.py +432 -0
- openehr_sdk/templates/opt_parser.py +352 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate template-specific builder classes from OPT files.
|
|
3
|
+
|
|
4
|
+
This module generates type-safe Python builder classes from parsed OPT
|
|
5
|
+
(Operational Template) files, eliminating the need for manual FLAT path construction.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from openehr_sdk.templates.opt_parser import parse_opt
|
|
9
|
+
>>> from openehr_sdk.templates.builder_generator import BuilderGenerator
|
|
10
|
+
>>>
|
|
11
|
+
>>> template = parse_opt("vital_signs.opt")
|
|
12
|
+
>>> generator = BuilderGenerator()
|
|
13
|
+
>>> code = generator.generate(template)
|
|
14
|
+
>>> print(code)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .opt_parser import ArchetypeNode, TemplateDefinition
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ObservationMetadata:
|
|
28
|
+
"""Metadata extracted from an OBSERVATION archetype."""
|
|
29
|
+
|
|
30
|
+
archetype_id: str
|
|
31
|
+
node_id: str
|
|
32
|
+
name: str
|
|
33
|
+
short_name: str # For method/variable names (e.g., "blood_pressure")
|
|
34
|
+
flat_path: str # Base FLAT path (e.g., "vital_signs/blood_pressure")
|
|
35
|
+
elements: list[ElementMetadata]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ElementMetadata:
|
|
40
|
+
"""Metadata for an ELEMENT within an observation."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
node_id: str
|
|
44
|
+
rm_type: str = "DV_QUANTITY" # Default to quantity
|
|
45
|
+
unit: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BuilderGenerator:
|
|
49
|
+
"""Generate builder classes from OPT templates."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
"""Initialize the builder generator."""
|
|
53
|
+
self._observations: list[ObservationMetadata] = []
|
|
54
|
+
self._template: TemplateDefinition | None = None
|
|
55
|
+
|
|
56
|
+
def generate(self, template: TemplateDefinition, class_name: str | None = None) -> str:
|
|
57
|
+
"""Generate a complete builder class from a template.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
template: Parsed template definition.
|
|
61
|
+
class_name: Optional custom class name (defaults to derived name).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Python source code for the builder class.
|
|
65
|
+
"""
|
|
66
|
+
# Store template for use in path generation
|
|
67
|
+
self._template = template
|
|
68
|
+
|
|
69
|
+
if class_name is None:
|
|
70
|
+
class_name = self._derive_class_name(template.template_id)
|
|
71
|
+
|
|
72
|
+
# Extract observations from template
|
|
73
|
+
self._observations = self._extract_observations(template)
|
|
74
|
+
|
|
75
|
+
# Generate code sections
|
|
76
|
+
imports = self._generate_imports()
|
|
77
|
+
class_def = self._generate_class_definition(template, class_name)
|
|
78
|
+
methods = self._generate_methods()
|
|
79
|
+
|
|
80
|
+
return f"""{imports}
|
|
81
|
+
|
|
82
|
+
{class_def}
|
|
83
|
+
{methods}"""
|
|
84
|
+
|
|
85
|
+
def generate_to_file(
|
|
86
|
+
self, template: TemplateDefinition, output_path: Path | str, class_name: str | None = None
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Generate builder class and write to file.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
template: Parsed template definition.
|
|
92
|
+
output_path: Path to write the generated Python file.
|
|
93
|
+
class_name: Optional custom class name.
|
|
94
|
+
"""
|
|
95
|
+
code = self.generate(template, class_name)
|
|
96
|
+
output_path = Path(output_path)
|
|
97
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
output_path.write_text(code)
|
|
99
|
+
|
|
100
|
+
def _derive_class_name(self, template_id: str) -> str:
|
|
101
|
+
"""Derive a Python class name from template ID.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
"IDCR - Vital Signs Encounter.v1" -> "VitalSignsEncounterBuilder"
|
|
105
|
+
"Problem List.v1" -> "ProblemListBuilder"
|
|
106
|
+
"""
|
|
107
|
+
# Remove version suffix
|
|
108
|
+
name = re.sub(r"\.v\d+$", "", template_id)
|
|
109
|
+
# Remove common prefixes
|
|
110
|
+
name = re.sub(r"^(IDCR|openEHR)\s*-\s*", "", name, flags=re.IGNORECASE)
|
|
111
|
+
# Convert to PascalCase
|
|
112
|
+
words = re.findall(r"[A-Za-z0-9]+", name)
|
|
113
|
+
pascal = "".join(word.capitalize() for word in words)
|
|
114
|
+
return f"{pascal}Builder"
|
|
115
|
+
|
|
116
|
+
def _derive_short_name(self, text: str) -> str:
|
|
117
|
+
"""Derive a Python identifier from text.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
"Blood Pressure" -> "blood_pressure"
|
|
121
|
+
"Pulse/Heart Beat" -> "pulse_heart_beat"
|
|
122
|
+
"""
|
|
123
|
+
# Replace non-alphanumeric with spaces
|
|
124
|
+
text = re.sub(r"[^A-Za-z0-9]+", " ", text)
|
|
125
|
+
# Convert to snake_case
|
|
126
|
+
words = text.lower().split()
|
|
127
|
+
return "_".join(words)
|
|
128
|
+
|
|
129
|
+
def _extract_observations(self, template: TemplateDefinition) -> list[ObservationMetadata]:
|
|
130
|
+
"""Extract all OBSERVATION archetypes from the template."""
|
|
131
|
+
observations = []
|
|
132
|
+
|
|
133
|
+
for node in template.list_observations():
|
|
134
|
+
obs = self._extract_observation_metadata(node)
|
|
135
|
+
if obs:
|
|
136
|
+
observations.append(obs)
|
|
137
|
+
|
|
138
|
+
return observations
|
|
139
|
+
|
|
140
|
+
def _extract_observation_metadata(self, node: ArchetypeNode) -> ObservationMetadata | None:
|
|
141
|
+
"""Extract metadata from an OBSERVATION node."""
|
|
142
|
+
if not node.archetype_id:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Derive names from archetype ID
|
|
146
|
+
# e.g., "openEHR-EHR-OBSERVATION.blood_pressure.v1" -> "blood_pressure"
|
|
147
|
+
archetype_parts = node.archetype_id.split(".")
|
|
148
|
+
if len(archetype_parts) >= 2:
|
|
149
|
+
short_name = archetype_parts[-2] # e.g., "blood_pressure"
|
|
150
|
+
else:
|
|
151
|
+
short_name = self._derive_short_name(node.name)
|
|
152
|
+
|
|
153
|
+
# Build FLAT path (simplified - assumes composition root)
|
|
154
|
+
flat_path = self._build_flat_path(node, short_name)
|
|
155
|
+
|
|
156
|
+
# Extract elements (data points like systolic, diastolic, rate, etc.)
|
|
157
|
+
elements = self._extract_elements(node)
|
|
158
|
+
|
|
159
|
+
return ObservationMetadata(
|
|
160
|
+
archetype_id=node.archetype_id,
|
|
161
|
+
node_id=node.node_id,
|
|
162
|
+
name=node.name,
|
|
163
|
+
short_name=short_name,
|
|
164
|
+
flat_path=flat_path,
|
|
165
|
+
elements=elements,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _build_flat_path(self, node: ArchetypeNode, short_name: str) -> str:
|
|
169
|
+
"""Build the FLAT format path prefix for an observation.
|
|
170
|
+
|
|
171
|
+
Derives the composition name from the template to build correct paths.
|
|
172
|
+
"""
|
|
173
|
+
# Derive composition name from template
|
|
174
|
+
composition_name = self._derive_composition_name()
|
|
175
|
+
|
|
176
|
+
# Standard pattern: "composition_name/observation_name"
|
|
177
|
+
return f"{composition_name}/{short_name}"
|
|
178
|
+
|
|
179
|
+
def _derive_composition_name(self) -> str:
|
|
180
|
+
"""Derive the composition name from the template.
|
|
181
|
+
|
|
182
|
+
Uses the template concept or template_id to create a snake_case name.
|
|
183
|
+
"""
|
|
184
|
+
if not self._template:
|
|
185
|
+
return "composition"
|
|
186
|
+
|
|
187
|
+
# Use concept if available, otherwise template_id
|
|
188
|
+
name = self._template.concept or self._template.template_id
|
|
189
|
+
|
|
190
|
+
# Remove common prefixes and suffixes
|
|
191
|
+
name = re.sub(r"\s+(composition|encounter|template)$", "", name, flags=re.IGNORECASE)
|
|
192
|
+
name = re.sub(r"^(IDCR|openEHR)\s*-\s*", "", name, flags=re.IGNORECASE)
|
|
193
|
+
|
|
194
|
+
# Convert to snake_case
|
|
195
|
+
return self._derive_short_name(name)
|
|
196
|
+
|
|
197
|
+
def _extract_elements(self, obs_node: ArchetypeNode) -> list[ElementMetadata]:
|
|
198
|
+
"""Extract ELEMENT nodes from an observation.
|
|
199
|
+
|
|
200
|
+
This traverses the OBSERVATION -> HISTORY -> EVENT -> ITEM_TREE -> ELEMENT path.
|
|
201
|
+
"""
|
|
202
|
+
elements = []
|
|
203
|
+
|
|
204
|
+
def traverse(node: ArchetypeNode, depth: int = 0) -> None:
|
|
205
|
+
"""Recursively find ELEMENT nodes."""
|
|
206
|
+
if node.rm_type == "ELEMENT" and node.name:
|
|
207
|
+
# Found a data element
|
|
208
|
+
element = ElementMetadata(
|
|
209
|
+
name=node.name,
|
|
210
|
+
node_id=node.node_id,
|
|
211
|
+
rm_type="DV_QUANTITY", # Default assumption
|
|
212
|
+
)
|
|
213
|
+
elements.append(element)
|
|
214
|
+
|
|
215
|
+
# Traverse children
|
|
216
|
+
for child in node.children:
|
|
217
|
+
traverse(child, depth + 1)
|
|
218
|
+
|
|
219
|
+
traverse(obs_node)
|
|
220
|
+
return elements
|
|
221
|
+
|
|
222
|
+
def _generate_imports(self) -> str:
|
|
223
|
+
"""Generate import statements."""
|
|
224
|
+
return '''"""
|
|
225
|
+
Generated template builder from OPT file.
|
|
226
|
+
|
|
227
|
+
This file was auto-generated. Do not edit manually.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
from __future__ import annotations
|
|
231
|
+
|
|
232
|
+
from datetime import datetime, timezone
|
|
233
|
+
from typing import Any
|
|
234
|
+
|
|
235
|
+
from ..serialization.flat import FlatBuilder
|
|
236
|
+
from .builders import TemplateBuilder'''
|
|
237
|
+
|
|
238
|
+
def _generate_class_definition(self, template: TemplateDefinition, class_name: str) -> str:
|
|
239
|
+
"""Generate the class definition and __init__ method."""
|
|
240
|
+
doc = template.description or "Template builder"
|
|
241
|
+
template_id = template.template_id
|
|
242
|
+
|
|
243
|
+
return f'''
|
|
244
|
+
|
|
245
|
+
class {class_name}(TemplateBuilder):
|
|
246
|
+
"""Builder for {template.concept}.
|
|
247
|
+
|
|
248
|
+
{doc}
|
|
249
|
+
|
|
250
|
+
Template ID: {template_id}
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
template_id = "{template_id}"'''
|
|
254
|
+
|
|
255
|
+
def _generate_methods(self) -> str:
|
|
256
|
+
"""Generate add_* methods for each observation."""
|
|
257
|
+
methods = []
|
|
258
|
+
|
|
259
|
+
for obs in self._observations:
|
|
260
|
+
method = self._generate_observation_method(obs)
|
|
261
|
+
methods.append(method)
|
|
262
|
+
|
|
263
|
+
return "\n\n".join(methods)
|
|
264
|
+
|
|
265
|
+
def _generate_observation_method(self, obs: ObservationMetadata) -> str:
|
|
266
|
+
"""Generate an add_* method for a specific observation type."""
|
|
267
|
+
method_name = f"add_{obs.short_name}"
|
|
268
|
+
|
|
269
|
+
# Generate parameters from elements
|
|
270
|
+
params = self._generate_method_params(obs.elements)
|
|
271
|
+
param_docs = self._generate_param_docs(obs.elements)
|
|
272
|
+
|
|
273
|
+
# Generate method body
|
|
274
|
+
body = self._generate_method_body(obs)
|
|
275
|
+
|
|
276
|
+
return f'''
|
|
277
|
+
def {method_name}(
|
|
278
|
+
self,
|
|
279
|
+
{params}
|
|
280
|
+
time: datetime | str | None = None,
|
|
281
|
+
event_index: int | None = None,
|
|
282
|
+
) -> TemplateBuilder:
|
|
283
|
+
"""Add a {obs.name} observation.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
{param_docs}
|
|
287
|
+
time: Measurement time (defaults to now).
|
|
288
|
+
event_index: Optional specific event index.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Self for method chaining.
|
|
292
|
+
"""
|
|
293
|
+
{body}
|
|
294
|
+
return self'''
|
|
295
|
+
|
|
296
|
+
def _generate_method_params(self, elements: list[ElementMetadata]) -> str:
|
|
297
|
+
"""Generate method parameters from elements."""
|
|
298
|
+
if not elements:
|
|
299
|
+
return ""
|
|
300
|
+
|
|
301
|
+
params = []
|
|
302
|
+
for elem in elements:
|
|
303
|
+
param_name = self._derive_short_name(elem.name)
|
|
304
|
+
params.append(f" {param_name}: float,")
|
|
305
|
+
|
|
306
|
+
return "\n".join(params)
|
|
307
|
+
|
|
308
|
+
def _generate_param_docs(self, elements: list[ElementMetadata]) -> str:
|
|
309
|
+
"""Generate parameter documentation."""
|
|
310
|
+
if not elements:
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
docs = []
|
|
314
|
+
for elem in elements:
|
|
315
|
+
param_name = self._derive_short_name(elem.name)
|
|
316
|
+
docs.append(f" {param_name}: {elem.name} value.")
|
|
317
|
+
|
|
318
|
+
return "\n".join(docs)
|
|
319
|
+
|
|
320
|
+
def _generate_method_body(self, obs: ObservationMetadata) -> str:
|
|
321
|
+
"""Generate the method body for adding an observation."""
|
|
322
|
+
lines = []
|
|
323
|
+
|
|
324
|
+
# Get event index
|
|
325
|
+
lines.append(" if event_index is None:")
|
|
326
|
+
lines.append(f' event_index = self._next_event_index("{obs.short_name}")')
|
|
327
|
+
lines.append("")
|
|
328
|
+
|
|
329
|
+
# Build path prefix
|
|
330
|
+
path_template = f"{obs.flat_path}:{{event_index}}/any_event:{{event_index}}"
|
|
331
|
+
lines.append(f' prefix = f"{path_template}"')
|
|
332
|
+
lines.append("")
|
|
333
|
+
|
|
334
|
+
# Set time
|
|
335
|
+
lines.append(" # Set time")
|
|
336
|
+
lines.append(" time_str = self._format_time(time)")
|
|
337
|
+
lines.append(' self._flat.set(f"{prefix}/time", time_str)')
|
|
338
|
+
lines.append("")
|
|
339
|
+
|
|
340
|
+
# Set elements
|
|
341
|
+
if obs.elements:
|
|
342
|
+
lines.append(" # Set measurements")
|
|
343
|
+
for elem in obs.elements:
|
|
344
|
+
elem_name = self._derive_short_name(elem.name)
|
|
345
|
+
elem_path = self._derive_short_name(elem.name)
|
|
346
|
+
# Default unit based on common patterns
|
|
347
|
+
unit = self._guess_unit(elem.name)
|
|
348
|
+
quantity_call = (
|
|
349
|
+
f" self._flat.set_quantity("
|
|
350
|
+
f'f"{{{{prefix}}}}/{elem_path}", {elem_name}, "{unit}")'
|
|
351
|
+
)
|
|
352
|
+
lines.append(quantity_call)
|
|
353
|
+
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
def _guess_unit(self, element_name: str) -> str:
|
|
357
|
+
"""Guess the unit for a data element based on its name."""
|
|
358
|
+
name_lower = element_name.lower()
|
|
359
|
+
|
|
360
|
+
# Blood pressure
|
|
361
|
+
if "systolic" in name_lower or "diastolic" in name_lower or "pressure" in name_lower:
|
|
362
|
+
return "mm[Hg]"
|
|
363
|
+
|
|
364
|
+
# Heart rate / pulse
|
|
365
|
+
if "rate" in name_lower or "pulse" in name_lower or "heart" in name_lower:
|
|
366
|
+
return "/min"
|
|
367
|
+
|
|
368
|
+
# Temperature
|
|
369
|
+
if "temperature" in name_lower or "temp" in name_lower:
|
|
370
|
+
return "Cel"
|
|
371
|
+
|
|
372
|
+
# Respiration
|
|
373
|
+
if "respiration" in name_lower or "breathing" in name_lower:
|
|
374
|
+
return "/min"
|
|
375
|
+
|
|
376
|
+
# Oxygen saturation
|
|
377
|
+
if "spo2" in name_lower or "saturation" in name_lower or "oxygen" in name_lower:
|
|
378
|
+
return "%"
|
|
379
|
+
|
|
380
|
+
# Weight
|
|
381
|
+
if "weight" in name_lower:
|
|
382
|
+
return "kg"
|
|
383
|
+
|
|
384
|
+
# Height
|
|
385
|
+
if "height" in name_lower:
|
|
386
|
+
return "cm"
|
|
387
|
+
|
|
388
|
+
# Default
|
|
389
|
+
return "1"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def generate_builder_from_opt(
|
|
393
|
+
opt_path: Path | str,
|
|
394
|
+
output_path: Path | str | None = None,
|
|
395
|
+
class_name: str | None = None,
|
|
396
|
+
) -> str:
|
|
397
|
+
"""Generate a builder class from an OPT file.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
opt_path: Path to the OPT XML file.
|
|
401
|
+
output_path: Optional path to write the generated code.
|
|
402
|
+
class_name: Optional custom class name.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Generated Python source code.
|
|
406
|
+
"""
|
|
407
|
+
from .opt_parser import parse_opt
|
|
408
|
+
|
|
409
|
+
template = parse_opt(opt_path)
|
|
410
|
+
generator = BuilderGenerator()
|
|
411
|
+
|
|
412
|
+
# Generate once
|
|
413
|
+
code = generator.generate(template, class_name)
|
|
414
|
+
|
|
415
|
+
# Write to file if requested
|
|
416
|
+
if output_path:
|
|
417
|
+
output_path = Path(output_path)
|
|
418
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
output_path.write_text(code)
|
|
420
|
+
|
|
421
|
+
return code
|