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,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serialization utilities for openEHR Reference Model objects.
|
|
3
|
+
|
|
4
|
+
This module provides functions for serializing and deserializing
|
|
5
|
+
openEHR RM objects to/from various formats:
|
|
6
|
+
|
|
7
|
+
- Canonical JSON: Standard openEHR JSON with _type discriminator
|
|
8
|
+
- FLAT format: Simplified format used by EHRBase
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .canonical import (
|
|
12
|
+
from_canonical,
|
|
13
|
+
get_type_registry,
|
|
14
|
+
register_type,
|
|
15
|
+
to_canonical,
|
|
16
|
+
)
|
|
17
|
+
from .flat import (
|
|
18
|
+
FlatBuilder,
|
|
19
|
+
FlatContext,
|
|
20
|
+
FlatPath,
|
|
21
|
+
flatten_dict,
|
|
22
|
+
unflatten_dict,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# Canonical JSON
|
|
27
|
+
"from_canonical",
|
|
28
|
+
"to_canonical",
|
|
29
|
+
"register_type",
|
|
30
|
+
"get_type_registry",
|
|
31
|
+
# FLAT format
|
|
32
|
+
"FlatBuilder",
|
|
33
|
+
"FlatContext",
|
|
34
|
+
"FlatPath",
|
|
35
|
+
"flatten_dict",
|
|
36
|
+
"unflatten_dict",
|
|
37
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical JSON serialization for openEHR Reference Model objects.
|
|
3
|
+
|
|
4
|
+
The canonical JSON format is the standard openEHR JSON serialization format,
|
|
5
|
+
which includes a `_type` field for polymorphic type identification.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from openehr_sdk.rm import DV_QUANTITY
|
|
9
|
+
>>> from openehr_sdk.serialization import to_canonical, from_canonical
|
|
10
|
+
>>>
|
|
11
|
+
>>> # Create a DV_QUANTITY
|
|
12
|
+
>>> bp = DV_QUANTITY(magnitude=120.0, units="mm[Hg]", property=...)
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Serialize to canonical JSON
|
|
15
|
+
>>> json_data = to_canonical(bp)
|
|
16
|
+
>>> # {
|
|
17
|
+
>>> # "_type": "DV_QUANTITY",
|
|
18
|
+
>>> # "magnitude": 120.0,
|
|
19
|
+
>>> # "units": "mm[Hg]",
|
|
20
|
+
>>> # ...
|
|
21
|
+
>>> # }
|
|
22
|
+
>>>
|
|
23
|
+
>>> # Deserialize back (with type detection)
|
|
24
|
+
>>> restored = from_canonical(json_data)
|
|
25
|
+
>>> assert isinstance(restored, DV_QUANTITY)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, TypeVar
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
# Type registry: maps _type string to class
|
|
35
|
+
_TYPE_REGISTRY: dict[str, type[BaseModel]] = {}
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T", bound=BaseModel)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_type(cls: type[T]) -> type[T]:
|
|
41
|
+
"""Register a type in the canonical JSON type registry.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cls: The Pydantic model class to register.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The same class (allows use as decorator).
|
|
48
|
+
"""
|
|
49
|
+
type_name = getattr(cls, "_type_", cls.__name__)
|
|
50
|
+
_TYPE_REGISTRY[type_name] = cls
|
|
51
|
+
return cls
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_type_registry() -> dict[str, type[BaseModel]]:
|
|
55
|
+
"""Get a copy of the type registry."""
|
|
56
|
+
return _TYPE_REGISTRY.copy()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_registry() -> None:
|
|
60
|
+
"""Build the type registry from all RM classes."""
|
|
61
|
+
if _TYPE_REGISTRY:
|
|
62
|
+
return # Already built
|
|
63
|
+
|
|
64
|
+
# Import all RM types and register them
|
|
65
|
+
from openehr_sdk import rm
|
|
66
|
+
|
|
67
|
+
for name in dir(rm):
|
|
68
|
+
cls = getattr(rm, name)
|
|
69
|
+
if isinstance(cls, type) and issubclass(cls, BaseModel) and cls is not BaseModel:
|
|
70
|
+
type_name = getattr(cls, "_type_", name)
|
|
71
|
+
_TYPE_REGISTRY[type_name] = cls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_canonical(
|
|
75
|
+
obj: BaseModel,
|
|
76
|
+
*,
|
|
77
|
+
exclude_none: bool = True,
|
|
78
|
+
by_alias: bool = False,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Serialize a Pydantic model to canonical JSON format.
|
|
81
|
+
|
|
82
|
+
The canonical format includes a `_type` field at the root level and
|
|
83
|
+
recursively in any nested objects that have a _type_ class attribute.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
obj: The Pydantic model to serialize.
|
|
87
|
+
exclude_none: Whether to exclude None values from output.
|
|
88
|
+
by_alias: Whether to use field aliases in output.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A dictionary suitable for JSON serialization.
|
|
92
|
+
"""
|
|
93
|
+
data = obj.model_dump(exclude_none=exclude_none, by_alias=by_alias)
|
|
94
|
+
|
|
95
|
+
# Add _type field
|
|
96
|
+
type_name = getattr(obj.__class__, "_type_", obj.__class__.__name__)
|
|
97
|
+
result = {"_type": type_name}
|
|
98
|
+
result.update(data)
|
|
99
|
+
|
|
100
|
+
# Recursively add _type to nested objects
|
|
101
|
+
_add_types_recursive(result, obj, exclude_none)
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _add_types_recursive(
|
|
107
|
+
data: dict[str, Any],
|
|
108
|
+
obj: BaseModel,
|
|
109
|
+
exclude_none: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Recursively add _type fields to nested objects."""
|
|
112
|
+
for key, value in list(data.items()):
|
|
113
|
+
if key == "_type":
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Get the corresponding attribute from the object
|
|
117
|
+
attr = getattr(obj, key, None)
|
|
118
|
+
|
|
119
|
+
if attr is None:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if isinstance(attr, BaseModel):
|
|
123
|
+
# Nested Pydantic model
|
|
124
|
+
type_name = getattr(attr.__class__, "_type_", attr.__class__.__name__)
|
|
125
|
+
nested_data = {"_type": type_name}
|
|
126
|
+
if isinstance(value, dict):
|
|
127
|
+
nested_data.update(value)
|
|
128
|
+
data[key] = nested_data
|
|
129
|
+
_add_types_recursive(nested_data, attr, exclude_none)
|
|
130
|
+
elif isinstance(attr, list):
|
|
131
|
+
# List of objects
|
|
132
|
+
for i, item in enumerate(attr):
|
|
133
|
+
if isinstance(item, BaseModel) and i < len(value):
|
|
134
|
+
type_name = getattr(item.__class__, "_type_", item.__class__.__name__)
|
|
135
|
+
if isinstance(value[i], dict):
|
|
136
|
+
nested_data = {"_type": type_name}
|
|
137
|
+
nested_data.update(value[i])
|
|
138
|
+
value[i] = nested_data
|
|
139
|
+
_add_types_recursive(nested_data, item, exclude_none)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def from_canonical(
|
|
143
|
+
data: dict[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
expected_type: type[T] | None = None,
|
|
146
|
+
) -> T | BaseModel:
|
|
147
|
+
"""Deserialize canonical JSON to a Pydantic model.
|
|
148
|
+
|
|
149
|
+
Uses the `_type` field to determine the correct class for polymorphic
|
|
150
|
+
deserialization.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
data: The canonical JSON data.
|
|
154
|
+
expected_type: Optional expected type. If provided, validates that
|
|
155
|
+
the deserialized object is of this type.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The deserialized Pydantic model.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If the _type is not recognized or doesn't match expected_type.
|
|
162
|
+
"""
|
|
163
|
+
_build_registry()
|
|
164
|
+
|
|
165
|
+
# Get the type from the data
|
|
166
|
+
type_name = data.get("_type")
|
|
167
|
+
if not type_name:
|
|
168
|
+
if expected_type:
|
|
169
|
+
return expected_type.model_validate(data)
|
|
170
|
+
raise ValueError("Missing _type field in canonical JSON data")
|
|
171
|
+
|
|
172
|
+
# Look up the class
|
|
173
|
+
cls = _TYPE_REGISTRY.get(type_name)
|
|
174
|
+
if not cls:
|
|
175
|
+
raise ValueError(f"Unknown type: {type_name}")
|
|
176
|
+
|
|
177
|
+
# Validate expected_type if provided
|
|
178
|
+
if expected_type and not issubclass(cls, expected_type):
|
|
179
|
+
raise ValueError(f"Type mismatch: expected {expected_type.__name__}, got {type_name}")
|
|
180
|
+
|
|
181
|
+
# Remove _type field from data before validation
|
|
182
|
+
clean_data = {k: v for k, v in data.items() if k != "_type"}
|
|
183
|
+
|
|
184
|
+
# Recursively process nested objects
|
|
185
|
+
_process_nested_types(clean_data)
|
|
186
|
+
|
|
187
|
+
return cls.model_validate(clean_data)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _process_nested_types(data: dict[str, Any]) -> None:
|
|
191
|
+
"""Recursively remove _type fields from nested data."""
|
|
192
|
+
for _key, value in data.items():
|
|
193
|
+
if isinstance(value, dict):
|
|
194
|
+
# Remove _type and recurse
|
|
195
|
+
if "_type" in value:
|
|
196
|
+
value.pop("_type")
|
|
197
|
+
_process_nested_types(value)
|
|
198
|
+
elif isinstance(value, list):
|
|
199
|
+
for item in value:
|
|
200
|
+
if isinstance(item, dict):
|
|
201
|
+
if "_type" in item:
|
|
202
|
+
item.pop("_type")
|
|
203
|
+
_process_nested_types(item)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FLAT format serialization for EHRBase.
|
|
3
|
+
|
|
4
|
+
The FLAT format is a simplified key-value representation used by EHRBase
|
|
5
|
+
for composition submission and retrieval. It flattens the hierarchical
|
|
6
|
+
openEHR composition structure into dot-separated paths.
|
|
7
|
+
|
|
8
|
+
Example FLAT format:
|
|
9
|
+
{
|
|
10
|
+
"ctx/language": "en",
|
|
11
|
+
"ctx/territory": "US",
|
|
12
|
+
"vital_signs/blood_pressure:0/any_event:0/systolic|magnitude": 120,
|
|
13
|
+
"vital_signs/blood_pressure:0/any_event:0/systolic|unit": "mm[Hg]",
|
|
14
|
+
...
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Note: Full FLAT format support requires template knowledge (OPT files).
|
|
18
|
+
This module provides utilities for working with FLAT format data.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FlatPath:
|
|
30
|
+
"""Represents a parsed FLAT format path."""
|
|
31
|
+
|
|
32
|
+
segments: list[str] = field(default_factory=list)
|
|
33
|
+
index: int | None = None
|
|
34
|
+
attribute: str | None = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse(cls, path: str) -> FlatPath:
|
|
38
|
+
"""Parse a FLAT format path string.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
- "ctx/language" -> FlatPath(["ctx", "language"])
|
|
42
|
+
- "vital_signs/bp:0/systolic|magnitude" ->
|
|
43
|
+
FlatPath(["vital_signs", "bp", "systolic"], 0, "magnitude")
|
|
44
|
+
"""
|
|
45
|
+
result = cls()
|
|
46
|
+
|
|
47
|
+
# Split by attribute separator first
|
|
48
|
+
if "|" in path:
|
|
49
|
+
path_part, result.attribute = path.rsplit("|", 1)
|
|
50
|
+
else:
|
|
51
|
+
path_part = path
|
|
52
|
+
|
|
53
|
+
# Split by path separator
|
|
54
|
+
parts = path_part.split("/")
|
|
55
|
+
|
|
56
|
+
for part in parts:
|
|
57
|
+
# Check for index notation (e.g., "bp:0")
|
|
58
|
+
match = re.match(r"^(.+):(\d+)$", part)
|
|
59
|
+
if match:
|
|
60
|
+
result.segments.append(match.group(1))
|
|
61
|
+
result.index = int(match.group(2))
|
|
62
|
+
else:
|
|
63
|
+
result.segments.append(part)
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
"""Convert back to FLAT path string."""
|
|
69
|
+
path = "/".join(self.segments)
|
|
70
|
+
if self.index is not None:
|
|
71
|
+
# Add index to the last segment
|
|
72
|
+
parts = path.rsplit("/", 1)
|
|
73
|
+
if len(parts) == 2:
|
|
74
|
+
path = f"{parts[0]}/{parts[1]}:{self.index}"
|
|
75
|
+
else:
|
|
76
|
+
path = f"{parts[0]}:{self.index}"
|
|
77
|
+
if self.attribute:
|
|
78
|
+
path = f"{path}|{self.attribute}"
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class FlatContext:
|
|
84
|
+
"""Context fields for FLAT format compositions."""
|
|
85
|
+
|
|
86
|
+
language: str = "en"
|
|
87
|
+
territory: str = "US"
|
|
88
|
+
composer_name: str | None = None
|
|
89
|
+
composer_id: str | None = None
|
|
90
|
+
id_scheme: str | None = None
|
|
91
|
+
id_namespace: str | None = None
|
|
92
|
+
health_care_facility_name: str | None = None
|
|
93
|
+
health_care_facility_id: str | None = None
|
|
94
|
+
time: str | None = None
|
|
95
|
+
end_time: str | None = None
|
|
96
|
+
history_origin: str | None = None
|
|
97
|
+
participation_name: str | None = None
|
|
98
|
+
participation_function: str | None = None
|
|
99
|
+
participation_mode: str | None = None
|
|
100
|
+
participation_id: str | None = None
|
|
101
|
+
|
|
102
|
+
def to_flat(self, prefix: str = "ctx") -> dict[str, Any]:
|
|
103
|
+
"""Convert context to FLAT format.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
prefix: Path prefix to use. Use "ctx" for legacy format,
|
|
107
|
+
or composition ID (e.g., "vital_signs_observations") for EHRBase 2.26.0+.
|
|
108
|
+
"""
|
|
109
|
+
result: dict[str, Any] = {
|
|
110
|
+
f"{prefix}/language|terminology": "ISO_639-1",
|
|
111
|
+
f"{prefix}/language|code": self.language,
|
|
112
|
+
f"{prefix}/territory|terminology": "ISO_3166-1",
|
|
113
|
+
f"{prefix}/territory|code": self.territory,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if self.composer_name:
|
|
117
|
+
result[f"{prefix}/composer|name"] = self.composer_name
|
|
118
|
+
if self.composer_id:
|
|
119
|
+
result[f"{prefix}/composer|id"] = self.composer_id
|
|
120
|
+
if self.id_scheme:
|
|
121
|
+
result[f"{prefix}/id_scheme"] = self.id_scheme
|
|
122
|
+
if self.id_namespace:
|
|
123
|
+
result[f"{prefix}/id_namespace"] = self.id_namespace
|
|
124
|
+
if self.health_care_facility_name:
|
|
125
|
+
result[f"{prefix}/health_care_facility|name"] = self.health_care_facility_name
|
|
126
|
+
if self.health_care_facility_id:
|
|
127
|
+
result[f"{prefix}/health_care_facility|id"] = self.health_care_facility_id
|
|
128
|
+
if self.time:
|
|
129
|
+
result[f"{prefix}/time"] = self.time
|
|
130
|
+
if self.end_time:
|
|
131
|
+
result[f"{prefix}/end_time"] = self.end_time
|
|
132
|
+
if self.history_origin:
|
|
133
|
+
result[f"{prefix}/history_origin"] = self.history_origin
|
|
134
|
+
if self.participation_name:
|
|
135
|
+
result[f"{prefix}/participation_name"] = self.participation_name
|
|
136
|
+
if self.participation_function:
|
|
137
|
+
result[f"{prefix}/participation_function"] = self.participation_function
|
|
138
|
+
if self.participation_mode:
|
|
139
|
+
result[f"{prefix}/participation_mode"] = self.participation_mode
|
|
140
|
+
if self.participation_id:
|
|
141
|
+
result[f"{prefix}/participation_id"] = self.participation_id
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_flat(cls, data: dict[str, Any]) -> FlatContext:
|
|
147
|
+
"""Create context from FLAT format data."""
|
|
148
|
+
return cls(
|
|
149
|
+
language=data.get("ctx/language", "en"),
|
|
150
|
+
territory=data.get("ctx/territory", "US"),
|
|
151
|
+
composer_name=data.get("ctx/composer_name"),
|
|
152
|
+
composer_id=data.get("ctx/composer_id"),
|
|
153
|
+
id_scheme=data.get("ctx/id_scheme"),
|
|
154
|
+
id_namespace=data.get("ctx/id_namespace"),
|
|
155
|
+
health_care_facility_name=data.get("ctx/health_care_facility|name"),
|
|
156
|
+
health_care_facility_id=data.get("ctx/health_care_facility|id"),
|
|
157
|
+
time=data.get("ctx/time"),
|
|
158
|
+
end_time=data.get("ctx/end_time"),
|
|
159
|
+
history_origin=data.get("ctx/history_origin"),
|
|
160
|
+
participation_name=data.get("ctx/participation_name"),
|
|
161
|
+
participation_function=data.get("ctx/participation_function"),
|
|
162
|
+
participation_mode=data.get("ctx/participation_mode"),
|
|
163
|
+
participation_id=data.get("ctx/participation_id"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def flatten_dict(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
|
168
|
+
"""Flatten a nested dictionary to FLAT format paths.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
data: Nested dictionary to flatten.
|
|
172
|
+
prefix: Prefix for all keys.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Flattened dictionary with path keys.
|
|
176
|
+
"""
|
|
177
|
+
result: dict[str, Any] = {}
|
|
178
|
+
|
|
179
|
+
for key, value in data.items():
|
|
180
|
+
new_key = f"{prefix}/{key}" if prefix else key
|
|
181
|
+
|
|
182
|
+
if isinstance(value, dict):
|
|
183
|
+
# Recursively flatten nested dicts
|
|
184
|
+
result.update(flatten_dict(value, new_key))
|
|
185
|
+
elif isinstance(value, list):
|
|
186
|
+
# Handle lists with index notation
|
|
187
|
+
for i, item in enumerate(value):
|
|
188
|
+
indexed_key = f"{new_key}:{i}"
|
|
189
|
+
if isinstance(item, dict):
|
|
190
|
+
result.update(flatten_dict(item, indexed_key))
|
|
191
|
+
else:
|
|
192
|
+
result[indexed_key] = item
|
|
193
|
+
else:
|
|
194
|
+
result[new_key] = value
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def unflatten_dict(data: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
"""Unflatten FLAT format paths to nested dictionary.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
data: Flattened dictionary with path keys.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Nested dictionary.
|
|
207
|
+
"""
|
|
208
|
+
result: dict[str, Any] = {}
|
|
209
|
+
|
|
210
|
+
for path, value in data.items():
|
|
211
|
+
parts = path.replace("|", "/").split("/")
|
|
212
|
+
current = result
|
|
213
|
+
|
|
214
|
+
for _i, part in enumerate(parts[:-1]):
|
|
215
|
+
# Check for index notation
|
|
216
|
+
match = re.match(r"^(.+):(\d+)$", part)
|
|
217
|
+
if match:
|
|
218
|
+
name, idx = match.group(1), int(match.group(2))
|
|
219
|
+
if name not in current:
|
|
220
|
+
current[name] = []
|
|
221
|
+
while len(current[name]) <= idx:
|
|
222
|
+
current[name].append({})
|
|
223
|
+
current = current[name][idx]
|
|
224
|
+
else:
|
|
225
|
+
if part not in current:
|
|
226
|
+
current[part] = {}
|
|
227
|
+
current = current[part]
|
|
228
|
+
|
|
229
|
+
# Set the final value
|
|
230
|
+
final_key = parts[-1]
|
|
231
|
+
match = re.match(r"^(.+):(\d+)$", final_key)
|
|
232
|
+
if match:
|
|
233
|
+
name, idx = match.group(1), int(match.group(2))
|
|
234
|
+
if name not in current:
|
|
235
|
+
current[name] = []
|
|
236
|
+
while len(current[name]) <= idx:
|
|
237
|
+
current[name].append(None)
|
|
238
|
+
current[name][idx] = value
|
|
239
|
+
else:
|
|
240
|
+
current[final_key] = value
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class FlatBuilder:
|
|
246
|
+
"""Builder for creating FLAT format compositions.
|
|
247
|
+
|
|
248
|
+
This provides a fluent API for constructing FLAT format data
|
|
249
|
+
without needing to know the exact path structure.
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> builder = FlatBuilder()
|
|
253
|
+
>>> builder.context(language="en", territory="US", composer_name="Dr. Smith")
|
|
254
|
+
>>> builder.set("vital_signs/blood_pressure:0/any_event:0/systolic|magnitude", 120)
|
|
255
|
+
>>> builder.set("vital_signs/blood_pressure:0/any_event:0/systolic|unit", "mm[Hg]")
|
|
256
|
+
>>> flat_data = builder.build()
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, composition_prefix: str | None = None) -> None:
|
|
260
|
+
"""Initialize the builder.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
composition_prefix: Composition ID prefix for EHRBase 2.26.0+ format.
|
|
264
|
+
If None, uses legacy "ctx" format.
|
|
265
|
+
"""
|
|
266
|
+
self._data: dict[str, Any] = {}
|
|
267
|
+
self._context = FlatContext()
|
|
268
|
+
self._composition_prefix = composition_prefix
|
|
269
|
+
|
|
270
|
+
def context(
|
|
271
|
+
self,
|
|
272
|
+
language: str = "en",
|
|
273
|
+
territory: str = "US",
|
|
274
|
+
composer_name: str | None = None,
|
|
275
|
+
**kwargs: Any,
|
|
276
|
+
) -> FlatBuilder:
|
|
277
|
+
"""Set context fields."""
|
|
278
|
+
self._context.language = language
|
|
279
|
+
self._context.territory = territory
|
|
280
|
+
if composer_name:
|
|
281
|
+
self._context.composer_name = composer_name
|
|
282
|
+
for key, value in kwargs.items():
|
|
283
|
+
if hasattr(self._context, key):
|
|
284
|
+
setattr(self._context, key, value)
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
def set(self, path: str, value: Any) -> FlatBuilder:
|
|
288
|
+
"""Set a value at the given FLAT path."""
|
|
289
|
+
self._data[path] = value
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def set_quantity(
|
|
293
|
+
self,
|
|
294
|
+
path: str,
|
|
295
|
+
magnitude: float,
|
|
296
|
+
unit: str,
|
|
297
|
+
precision: int | None = None,
|
|
298
|
+
) -> FlatBuilder:
|
|
299
|
+
"""Set a DV_QUANTITY at the given path."""
|
|
300
|
+
self._data[f"{path}|magnitude"] = magnitude
|
|
301
|
+
self._data[f"{path}|unit"] = unit
|
|
302
|
+
if precision is not None:
|
|
303
|
+
self._data[f"{path}|precision"] = precision
|
|
304
|
+
return self
|
|
305
|
+
|
|
306
|
+
def set_coded_text(
|
|
307
|
+
self,
|
|
308
|
+
path: str,
|
|
309
|
+
value: str,
|
|
310
|
+
code: str,
|
|
311
|
+
terminology: str = "local",
|
|
312
|
+
) -> FlatBuilder:
|
|
313
|
+
"""Set a DV_CODED_TEXT at the given path."""
|
|
314
|
+
self._data[f"{path}|value"] = value
|
|
315
|
+
self._data[f"{path}|code"] = code
|
|
316
|
+
self._data[f"{path}|terminology"] = terminology
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def set_proportion(self, path: str, numerator: float, denominator: float) -> FlatBuilder:
|
|
320
|
+
"""Set a DV_PROPORTION at the given path."""
|
|
321
|
+
self._data[f"{path}|numerator"] = numerator
|
|
322
|
+
self._data[f"{path}|denominator"] = denominator
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def set_text(self, path: str, value: str) -> FlatBuilder:
|
|
326
|
+
"""Set a DV_TEXT at the given path."""
|
|
327
|
+
self._data[path] = value
|
|
328
|
+
return self
|
|
329
|
+
|
|
330
|
+
def set_datetime(self, path: str, value: str) -> FlatBuilder:
|
|
331
|
+
"""Set a DV_DATE_TIME at the given path."""
|
|
332
|
+
self._data[path] = value
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def build(self) -> dict[str, Any]:
|
|
336
|
+
"""Build the final FLAT format dictionary."""
|
|
337
|
+
prefix = self._composition_prefix if self._composition_prefix else "ctx"
|
|
338
|
+
result = self._context.to_flat(prefix=prefix)
|
|
339
|
+
|
|
340
|
+
# Add category and context fields for EHRBase 2.26.0+ format
|
|
341
|
+
if self._composition_prefix:
|
|
342
|
+
from datetime import datetime, timezone
|
|
343
|
+
|
|
344
|
+
# Add category
|
|
345
|
+
result[f"{self._composition_prefix}/category|terminology"] = "openehr"
|
|
346
|
+
result[f"{self._composition_prefix}/category|code"] = "433"
|
|
347
|
+
result[f"{self._composition_prefix}/category|value"] = "event"
|
|
348
|
+
|
|
349
|
+
# Add context/start_time if not already set
|
|
350
|
+
context_time_key = f"{self._composition_prefix}/context/start_time"
|
|
351
|
+
if context_time_key not in result and context_time_key not in self._data:
|
|
352
|
+
result[context_time_key] = datetime.now(timezone.utc).isoformat()
|
|
353
|
+
|
|
354
|
+
# Add context/setting if not already set
|
|
355
|
+
context_setting_code = f"{self._composition_prefix}/context/setting|code"
|
|
356
|
+
if context_setting_code not in result and context_setting_code not in self._data:
|
|
357
|
+
result[f"{self._composition_prefix}/context/setting|terminology"] = "openehr"
|
|
358
|
+
result[context_setting_code] = "238"
|
|
359
|
+
result[f"{self._composition_prefix}/context/setting|value"] = "other care"
|
|
360
|
+
|
|
361
|
+
result.update(self._data)
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# Re-export for convenience
|
|
366
|
+
__all__ = [
|
|
367
|
+
"FlatPath",
|
|
368
|
+
"FlatContext",
|
|
369
|
+
"FlatBuilder",
|
|
370
|
+
"flatten_dict",
|
|
371
|
+
"unflatten_dict",
|
|
372
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Template builders and OPT parsing for openEHR.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- OPT (Operational Template) XML parser
|
|
6
|
+
- Template-specific composition builders
|
|
7
|
+
- Pre-built builders for common templates
|
|
8
|
+
- OPT-to-Builder code generator
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .builder_generator import (
|
|
12
|
+
BuilderGenerator,
|
|
13
|
+
generate_builder_from_opt,
|
|
14
|
+
)
|
|
15
|
+
from .builders import (
|
|
16
|
+
TemplateBuilder,
|
|
17
|
+
VitalSignsBuilder,
|
|
18
|
+
)
|
|
19
|
+
from .opt_parser import (
|
|
20
|
+
ArchetypeNode,
|
|
21
|
+
ConstraintDefinition,
|
|
22
|
+
OPTParser,
|
|
23
|
+
TemplateDefinition,
|
|
24
|
+
parse_opt,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# OPT Parser
|
|
29
|
+
"OPTParser",
|
|
30
|
+
"TemplateDefinition",
|
|
31
|
+
"ArchetypeNode",
|
|
32
|
+
"ConstraintDefinition",
|
|
33
|
+
"parse_opt",
|
|
34
|
+
# Builder Generator
|
|
35
|
+
"BuilderGenerator",
|
|
36
|
+
"generate_builder_from_opt",
|
|
37
|
+
# Builders
|
|
38
|
+
"TemplateBuilder",
|
|
39
|
+
"VitalSignsBuilder",
|
|
40
|
+
]
|