labfreed 0.0.3__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.
- labfreed/DisplayNameExtension/DisplayNameExtension.py +34 -0
- labfreed/DisplayNameExtension/base36.py +66 -0
- labfreed/PAC_CAT/__init__.py +1 -0
- labfreed/PAC_CAT/data_model.py +109 -0
- labfreed/PAC_ID/__init__.py +0 -0
- labfreed/PAC_ID/data_model.py +114 -0
- labfreed/PAC_ID/parse.py +133 -0
- labfreed/PAC_ID/serialize.py +57 -0
- labfreed/TREXExtension/data_model.py +239 -0
- labfreed/TREXExtension/parse.py +46 -0
- labfreed/TREXExtension/uncertainty.py +32 -0
- labfreed/TREXExtension/unit_utilities.py +134 -0
- labfreed/__init__.py +5 -0
- labfreed-0.0.3.dist-info/METADATA +15 -0
- labfreed-0.0.3.dist-info/RECORD +17 -0
- labfreed-0.0.3.dist-info/WHEEL +4 -0
- labfreed-0.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from ..PAC_ID.data_model import Extension
|
|
4
|
+
from .base36 import from_base36, to_base36
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DisplayNames(Extension, BaseModel):
|
|
8
|
+
display_names: list[str]
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def name(self)->str:
|
|
12
|
+
return 'N'
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def type(self)->str:
|
|
16
|
+
return 'N'
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def data(self)->str:
|
|
20
|
+
return '/'.join([to_base36(dn) for dn in self.display_names])
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def from_spec_fields(name, type, data):
|
|
24
|
+
if name != 'N':
|
|
25
|
+
logging.warning(f'Name {name} was given, but this extension should only be used with name "N". Will ignore input')
|
|
26
|
+
|
|
27
|
+
if type != 'N':
|
|
28
|
+
logging.warning(f'Type {name} was given, but this extension should only be used with type "N". Will try to parse data as display names')
|
|
29
|
+
|
|
30
|
+
display_names = [from_base36(b36) for b36 in data.split('/')]
|
|
31
|
+
|
|
32
|
+
return DisplayNames(display_names=display_names)
|
|
33
|
+
|
|
34
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import string
|
|
2
|
+
|
|
3
|
+
def alphabet(base):
|
|
4
|
+
""" returns an alphabet, which corresponds to what pythons int(s:str, base:int=10) function used.
|
|
5
|
+
"""
|
|
6
|
+
if base < 2 or base > 36:
|
|
7
|
+
ValueError('base can only be between 2 and 36')
|
|
8
|
+
alphabet = (string.digits + string.ascii_uppercase)[0:base]
|
|
9
|
+
return alphabet
|
|
10
|
+
|
|
11
|
+
def to_base36(s:str):
|
|
12
|
+
"""Takes a string, encodes it in UTF-8 and then as base36 string."""
|
|
13
|
+
utf8_encoded = s.encode('utf-8')
|
|
14
|
+
num = int.from_bytes(utf8_encoded, byteorder='big', signed=False)
|
|
15
|
+
|
|
16
|
+
# note: this cannot be arbitrarily chosen. The choice here corresponds to what pythons int(s:str, base:int=10) function used.
|
|
17
|
+
base36_chars = alphabet(base=36)
|
|
18
|
+
if num == 0:
|
|
19
|
+
return base36_chars[0]
|
|
20
|
+
base36 = []
|
|
21
|
+
_num = num
|
|
22
|
+
while _num:
|
|
23
|
+
_num, i = divmod(_num, 36)
|
|
24
|
+
base36.append(base36_chars[i])
|
|
25
|
+
return ''.join(reversed(base36))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def from_base36(s36:str):
|
|
29
|
+
"""inverse of to_base36"""
|
|
30
|
+
# this built in function interprets each character as number in a base represented by the standartd alphabet [0-9 (A-Z|a-z)][0:base] it is case INsensitive.
|
|
31
|
+
num = int(s36, 36)
|
|
32
|
+
num_bytes = (num.bit_length() + 7) // 8
|
|
33
|
+
_bytes = num.to_bytes(num_bytes, byteorder='big')
|
|
34
|
+
s = _bytes.decode('utf-8')
|
|
35
|
+
return s
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
ss = ["A",
|
|
39
|
+
"B-500 B",
|
|
40
|
+
"B-500 Ba",
|
|
41
|
+
"B-500 Bal",
|
|
42
|
+
"B-500 Bala",
|
|
43
|
+
"B-500 Balanc",
|
|
44
|
+
"B-500 Balance",
|
|
45
|
+
"B-500 D",
|
|
46
|
+
"Mini Spray Dryer S-300",
|
|
47
|
+
"w3ApashAt!!£NAGDSAF*ç%&/()",
|
|
48
|
+
"HELLOWORLD",
|
|
49
|
+
"Helloworld",
|
|
50
|
+
"$£äö!'?^{]<@#¦&¬|¢)&§°😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉😀你好🌍🏯😇🎵🔥你👻🐉",
|
|
51
|
+
"往跟住!師立甲錯什正再圓身升因月室",
|
|
52
|
+
"Balance BAL500 @☣️Lab",
|
|
53
|
+
"BAL500 @☣️Lab",
|
|
54
|
+
"BAL-CLEAN",
|
|
55
|
+
"Smørrebrød µ-Nutrients",
|
|
56
|
+
"Demo Result from R-300",
|
|
57
|
+
"Rotavapor R-300",
|
|
58
|
+
"Rotavapor R-250",
|
|
59
|
+
"Rotavapor R-220",
|
|
60
|
+
"SyncorePlus"
|
|
61
|
+
]
|
|
62
|
+
for s in ss:
|
|
63
|
+
s36 = to_base36(s)
|
|
64
|
+
s_back = from_base36(s36)
|
|
65
|
+
identical = (s == s_back)
|
|
66
|
+
print(f'{s} >> {s36} >> {s_back}: match:{identical}')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..PAC_ID.data_model import IDSegment, Category
|
|
6
|
+
|
|
7
|
+
class CATBase(BaseModel, ABC):
|
|
8
|
+
category_key:str
|
|
9
|
+
additional_segments: list[IDSegment] = Field(default_factory=list)
|
|
10
|
+
|
|
11
|
+
class Config:
|
|
12
|
+
populate_by_name = True # this will allow field names, as well as aliases in validation
|
|
13
|
+
|
|
14
|
+
def to_identifier_category(self, use_short_notation=False):
|
|
15
|
+
'''Creates a Category with the correct segments.
|
|
16
|
+
Segments are in order of the Pydantic model fields.
|
|
17
|
+
Segment keys are omitted as long as the recommendation is followed.
|
|
18
|
+
Additional segments are added at the end'''
|
|
19
|
+
segments = []
|
|
20
|
+
can_omit_keys = use_short_notation # keeps track of whether keys can still be omitted. That is the case when the segment recommendation is followed
|
|
21
|
+
for field_name, field_info in self.model_fields.items():
|
|
22
|
+
if field_name in ['category_key', 'additional_segments']:
|
|
23
|
+
continue
|
|
24
|
+
if value := getattr(self, field_name):
|
|
25
|
+
if can_omit_keys:
|
|
26
|
+
key = None
|
|
27
|
+
else:
|
|
28
|
+
key = field_info.alias
|
|
29
|
+
segments.append(IDSegment(key= key, value= value) )
|
|
30
|
+
else:
|
|
31
|
+
can_omit_keys = False
|
|
32
|
+
if self.additional_segments:
|
|
33
|
+
segments.extend(self.additional_segments)
|
|
34
|
+
return Category(key=self.category_key,
|
|
35
|
+
segments=segments)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Material_Device(CATBase):
|
|
41
|
+
category_key: str = Field(default='-MD', frozen=True)
|
|
42
|
+
model_number: str = Field( alias='240', min_length=1)
|
|
43
|
+
serial_number: str = Field( alias='21', min_length=1)
|
|
44
|
+
|
|
45
|
+
class Material_Substance(CATBase):
|
|
46
|
+
category_key: str = Field(default='-MS', frozen=True)
|
|
47
|
+
product_number:str = Field( alias='240', min_length=1)
|
|
48
|
+
batch_number:str|None = Field(default=None, alias='10')
|
|
49
|
+
container_size:str|None = Field(default=None, alias='20')
|
|
50
|
+
container_number:str|None = Field(default=None, alias='21')
|
|
51
|
+
aliquot:str|None = Field(default=None, alias='250')
|
|
52
|
+
|
|
53
|
+
class Material_Consumable(CATBase):
|
|
54
|
+
category_key: str = Field(default='-MC', frozen=True)
|
|
55
|
+
product_number:str = Field( alias='240', min_length=1)
|
|
56
|
+
batch_number:str|None = Field(default=None, alias='10')
|
|
57
|
+
packing_size:str|None = Field(default=None, alias='20')
|
|
58
|
+
serial_number:str|None = Field(default=None, alias='21')
|
|
59
|
+
aliquot:str|None = Field(default=None, alias='250')
|
|
60
|
+
|
|
61
|
+
class Material_Misc(Material_Consumable):
|
|
62
|
+
category_key: str = Field(default='-MM', frozen=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Data_Result(CATBase):
|
|
67
|
+
category_key: str = Field(default='-DR', frozen=True)
|
|
68
|
+
id:str = Field( alias='240', min_length=1)
|
|
69
|
+
|
|
70
|
+
class Data_Method(CATBase):
|
|
71
|
+
category_key: str = Field(default='-DM', frozen=True)
|
|
72
|
+
id:str = Field( alias='240', min_length=1)
|
|
73
|
+
|
|
74
|
+
class Data_Calibration(CATBase):
|
|
75
|
+
category_key: str = Field(default='-DC', frozen=True)
|
|
76
|
+
id:str = Field( alias='240', min_length=1)
|
|
77
|
+
|
|
78
|
+
class Data_Progress(CATBase):
|
|
79
|
+
category_key: str = Field(default='-DP', frozen=True)
|
|
80
|
+
id:str = Field( alias='240', min_length=1)
|
|
81
|
+
|
|
82
|
+
class Data_Static(CATBase):
|
|
83
|
+
category_key: str = Field(default='-DS', frozen=True)
|
|
84
|
+
id:str = Field( alias='240', min_length=1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
mapping = {
|
|
90
|
+
'-MD': Material_Device,
|
|
91
|
+
'-MS': Material_Substance,
|
|
92
|
+
'-MC': Material_Consumable,
|
|
93
|
+
'-MM': Material_Misc,
|
|
94
|
+
'-DM': Data_Method,
|
|
95
|
+
'-DR': Data_Result,
|
|
96
|
+
'-DC': Data_Calibration,
|
|
97
|
+
'-DP': Data_Progress,
|
|
98
|
+
'-DS': Data_Static
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def CAT_from_category(category:Category) -> CATBase|None:
|
|
102
|
+
raise NotImplementedError()
|
|
103
|
+
|
|
104
|
+
def CAT_from_category_key(category_key) -> CATBase|None:
|
|
105
|
+
return mapping.get(category_key)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from typing_extensions import Self
|
|
3
|
+
from pydantic import BaseModel, Field, computed_field, conlist, model_validator
|
|
4
|
+
from abc import ABC, abstractproperty, abstractstaticmethod
|
|
5
|
+
|
|
6
|
+
class IDSegment(BaseModel):
|
|
7
|
+
key:Optional[str] = Field(None, pattern=r'^[A-Z0-9-+]+$', min_length=1)
|
|
8
|
+
value:str = Field(..., pattern=r'^[A-Z0-9-+]+$', min_length=1)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Category(BaseModel):
|
|
12
|
+
key:str|None = None
|
|
13
|
+
segments: list[IDSegment]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Identifier(BaseModel):
|
|
17
|
+
segments: conlist(IDSegment, min_length=1) = Field(..., exclude=True) # exclude=True prevents this from being serialized by Pydantic
|
|
18
|
+
|
|
19
|
+
@computed_field
|
|
20
|
+
@property
|
|
21
|
+
def categories(self) -> list[Category]:
|
|
22
|
+
categories = list()
|
|
23
|
+
c = Category(segments=[])
|
|
24
|
+
categories.append(c)
|
|
25
|
+
for s in self.segments:
|
|
26
|
+
# new category starts with "-"
|
|
27
|
+
if s.value[0] == '-':
|
|
28
|
+
cat_key = s.value
|
|
29
|
+
c = Category(key=cat_key, segments=[])
|
|
30
|
+
categories.append(c)
|
|
31
|
+
else:
|
|
32
|
+
c.segments.append(s)
|
|
33
|
+
|
|
34
|
+
# the first category might have no segments. remove categories without segments
|
|
35
|
+
if not categories[0].segments:
|
|
36
|
+
categories = categories[1:]
|
|
37
|
+
|
|
38
|
+
return categories
|
|
39
|
+
|
|
40
|
+
@model_validator(mode='after')
|
|
41
|
+
def check_keys_are_unique_in_each_category(self) -> Self:
|
|
42
|
+
for c in self.categories:
|
|
43
|
+
keys = [s.key for s in c.segments if s.key]
|
|
44
|
+
duplicate_keys = [k for k in set(keys) if keys.count(k) > 1]
|
|
45
|
+
if duplicate_keys:
|
|
46
|
+
raise ValueError(f'Duplicate keys {",".join(duplicate_keys)} in category {c.key}')
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def from_categories(categories:list[Category]) :
|
|
51
|
+
segments = list()
|
|
52
|
+
for c in categories:
|
|
53
|
+
if c.key:
|
|
54
|
+
segments.append(IDSegment(value=c.key))
|
|
55
|
+
segments.extend(c.segments)
|
|
56
|
+
return Identifier(segments=segments)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Extension(ABC, BaseModel):
|
|
61
|
+
|
|
62
|
+
@abstractproperty
|
|
63
|
+
def name(self)->str:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractproperty
|
|
67
|
+
def type(self)->str:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abstractproperty
|
|
71
|
+
def data(self)->str:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@abstractstaticmethod
|
|
75
|
+
def from_spec_fields(name, type, data):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UnknownExtension(Extension):
|
|
80
|
+
name_:str
|
|
81
|
+
type_:str
|
|
82
|
+
data_:str
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self)->str:
|
|
86
|
+
return self.name_
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def type(self)->str:
|
|
90
|
+
return self.type_
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def data(self)->str:
|
|
94
|
+
return self.data_
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def from_spec_fields(name, type, data):
|
|
98
|
+
return UnknownExtension(name_=name, type_=type, data_=data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PACID(BaseModel):
|
|
103
|
+
issuer:str
|
|
104
|
+
identifier: Identifier
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PACID_With_Extensions(BaseModel):
|
|
108
|
+
pac_id: PACID
|
|
109
|
+
extensions: list[Extension] = Field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
labfreed/PAC_ID/parse.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from .data_model import *
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
category_conventions = MappingProxyType(
|
|
9
|
+
{
|
|
10
|
+
'-MD': ['240', '21'],
|
|
11
|
+
'-MS': ['240', '10', '20', '21', '250'],
|
|
12
|
+
'-MC': ['240', '10', '20', '21', '250'],
|
|
13
|
+
'-MM': ['240', '10', '20', '21', '250']
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
extension_convention = MappingProxyType(
|
|
19
|
+
{
|
|
20
|
+
0: { 'name': 'N', 'type': 'N'},
|
|
21
|
+
1: { 'name': 'SUM', 'type': 'TREX'}
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PAC_Parser():
|
|
28
|
+
|
|
29
|
+
def __init__(self, extension_interpreters:dict[str, Extension]=None):
|
|
30
|
+
self.extension_interpreters = extension_interpreters or {}
|
|
31
|
+
|
|
32
|
+
def parse_pac_url(self, pac_url:str) -> PACID_With_Extensions:
|
|
33
|
+
if '*' in pac_url:
|
|
34
|
+
id_str, ext_str = pac_url.split('*', 1)
|
|
35
|
+
else:
|
|
36
|
+
id_str = pac_url
|
|
37
|
+
ext_str = ""
|
|
38
|
+
|
|
39
|
+
pac_id = self.parse_pac_id(id_str)
|
|
40
|
+
extensions = self.parse_extensions(ext_str)
|
|
41
|
+
return PACID_With_Extensions(pac_id=pac_id, extensions=extensions)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_id_segments(self, identifier:str):
|
|
45
|
+
if not identifier:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
id_segments = list()
|
|
49
|
+
for s in identifier.split('/'):
|
|
50
|
+
tmp = s.split(':')
|
|
51
|
+
|
|
52
|
+
if len(tmp) == 1:
|
|
53
|
+
segment = IDSegment(value=tmp[0])
|
|
54
|
+
elif len(tmp) == 2:
|
|
55
|
+
segment = IDSegment(key=tmp[0], value=tmp[1])
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f'invalid segment: {s}')
|
|
58
|
+
|
|
59
|
+
id_segments.append(segment)
|
|
60
|
+
return id_segments
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _apply_category_defaults(self, segments_in: list[IDSegment]):
|
|
64
|
+
|
|
65
|
+
segments = segments_in.copy()
|
|
66
|
+
default_keys = None
|
|
67
|
+
for s in segments:
|
|
68
|
+
if not s.key and default_keys:
|
|
69
|
+
s.key = default_keys.pop(0)
|
|
70
|
+
else:
|
|
71
|
+
default_keys = None
|
|
72
|
+
|
|
73
|
+
# category starts: start with new defaults.
|
|
74
|
+
if s.value in category_conventions.keys():
|
|
75
|
+
default_keys = category_conventions.get(s.value).copy() #copy, so the entries can be popped when used
|
|
76
|
+
return segments
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_pac_id(self,id_str:str) -> PACID:
|
|
81
|
+
m = re.match(f'(HTTPS://)?(PAC.)?(?P<issuer>.+?\..+?)/(?P<identifier>.*)', id_str)
|
|
82
|
+
d = m.groupdict()
|
|
83
|
+
|
|
84
|
+
id_segments = list()
|
|
85
|
+
default_keys = None
|
|
86
|
+
id_segments = self.parse_id_segments(d.get('identifier'))
|
|
87
|
+
id_segments = self._apply_category_defaults(id_segments)
|
|
88
|
+
|
|
89
|
+
return PACID(issuer= d.get('issuer'),
|
|
90
|
+
identifier=Identifier(segments=id_segments)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_extensions(self, extensions_str:str|None) -> list[Extension]:
|
|
95
|
+
extensions = list()
|
|
96
|
+
|
|
97
|
+
if not extensions_str:
|
|
98
|
+
return extensions
|
|
99
|
+
|
|
100
|
+
defaults = extension_convention
|
|
101
|
+
for i, e in enumerate(extensions_str.split('*')):
|
|
102
|
+
if e == '': #this will happen if first extension starts with *
|
|
103
|
+
continue
|
|
104
|
+
d = re.match('((?P<name>.+)\$(?P<type>.+)/)?(?P<data>.+)', e).groupdict()
|
|
105
|
+
|
|
106
|
+
name = d.get('name')
|
|
107
|
+
type = d.get('type')
|
|
108
|
+
data = d.get('data')
|
|
109
|
+
|
|
110
|
+
if name:
|
|
111
|
+
defaults = None # once a name was specified no longer assign defaults
|
|
112
|
+
else:
|
|
113
|
+
if defaults:
|
|
114
|
+
name = defaults.get(i).get('name')
|
|
115
|
+
type = defaults.get(i).get('type')
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError('extension number {i}, must have name and type')
|
|
118
|
+
|
|
119
|
+
#convert to subtype if they were given
|
|
120
|
+
subtype = self.extension_interpreters.get(type) or UnknownExtension
|
|
121
|
+
e = subtype.from_spec_fields(name=name, type=type, data=data)
|
|
122
|
+
extensions.append(e)
|
|
123
|
+
|
|
124
|
+
return extensions
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
pacid_str = 'HTTPS://PAC.METTORIUS.COM/-DR/AB378/-MD/B-500/1235/-MS/AB/X:88/WWW/-MS/240:11/BB*ABCFD*A$HUR:25+B$CEL:99*BLUBB$TREX/A$HUR:25+B$CEL:99'
|
|
132
|
+
|
|
133
|
+
pac = PAC_Parser().parse_pac(pacid_str)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
from .data_model import *
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PAC_Serializer():
|
|
7
|
+
def to_url(self, pac:PACID|PACID_With_Extensions, extensions:list[Extension]=None, use_short_notation_for_extensions=False) -> str:
|
|
8
|
+
if isinstance(pac, PACID_With_Extensions):
|
|
9
|
+
if extensions:
|
|
10
|
+
raise ValueError('Extensions were given twice, as part of PACID_With_Extension and as method parameter.')
|
|
11
|
+
extensions = pac.extensions
|
|
12
|
+
pac = pac.pac_id
|
|
13
|
+
issuer = pac.issuer
|
|
14
|
+
extensions_str = self._serialize_extensions(extensions, use_short_notation_for_extensions)
|
|
15
|
+
id_segments = self._serialize_id_segments(pac.identifier.segments)
|
|
16
|
+
return f"HTTPS://PAC.{issuer}{id_segments}{extensions_str}".upper()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _serialize_id_segments(self, segments):
|
|
20
|
+
out = ''
|
|
21
|
+
for s in segments:
|
|
22
|
+
if s.key:
|
|
23
|
+
out += f'/{s.key}:{s.value}'
|
|
24
|
+
else:
|
|
25
|
+
out += f'/{s.value}'
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _serialize_extensions(self, extensions:list[Extension], use_short_notation_for_extensions):
|
|
30
|
+
out = ''
|
|
31
|
+
short_notation = use_short_notation_for_extensions
|
|
32
|
+
for i, e in enumerate(extensions):
|
|
33
|
+
|
|
34
|
+
if short_notation and i==0:
|
|
35
|
+
if e.name=='N':
|
|
36
|
+
out += f'*{e.data}'
|
|
37
|
+
continue
|
|
38
|
+
else:
|
|
39
|
+
short_notation = False
|
|
40
|
+
if short_notation and i==1:
|
|
41
|
+
if e.name=='SUM':
|
|
42
|
+
out += f'*{e.data}'
|
|
43
|
+
continue
|
|
44
|
+
else:
|
|
45
|
+
short_notation = False
|
|
46
|
+
|
|
47
|
+
out += f'*{e.name}${e.type}/{e.data}'
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import Enum
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError, field_validator
|
|
7
|
+
from abc import ABC
|
|
8
|
+
|
|
9
|
+
from .unit_utilities import *
|
|
10
|
+
from ..PAC_ID.data_model import Extension
|
|
11
|
+
|
|
12
|
+
re_table_pattern = re.compile(f"(?P<tablename>[\w\.-]*?)\$\$(?P<header>[\w\.,\$:]*?)::(?P<body>.*)")
|
|
13
|
+
re_col_head_pattern = re.compile(f"(?P<name>[\w\.-]*?)\$(?P<unit>[\w\.]*)")
|
|
14
|
+
re_scalar_pattern = re.compile(f"(?P<name>[\w\.-]*?)\$(?P<unit>[\w\.]*?):(?P<value>.*)")
|
|
15
|
+
|
|
16
|
+
TREX_DATEFORMAT = '%Y%m%dT%H%M%S'
|
|
17
|
+
TREX_TIMEFORMAT = '%Y%m%d'
|
|
18
|
+
|
|
19
|
+
class TREX_types(Enum):
|
|
20
|
+
BOOL = 'T.B'
|
|
21
|
+
DATE = 'T.D'
|
|
22
|
+
TEXT = 'T.A'
|
|
23
|
+
ERROR = 'E'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class T_REX_Segment_ParseError(BaseException):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TREX_Segment(BaseModel, ABC):
|
|
32
|
+
segment_name: str = None
|
|
33
|
+
|
|
34
|
+
def as_trex_segment_str(self, segment_name):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TREX_SimpleSegment(TREX_Segment):
|
|
39
|
+
type: str
|
|
40
|
+
value: str
|
|
41
|
+
|
|
42
|
+
@field_validator('type', mode='before')
|
|
43
|
+
def validate_type(t):
|
|
44
|
+
if isinstance(t, TREX_types):
|
|
45
|
+
t = t.value
|
|
46
|
+
return t
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_trex_segmentstring(segment_str):
|
|
50
|
+
|
|
51
|
+
matches = re_scalar_pattern.match(segment_str)
|
|
52
|
+
if not matches:
|
|
53
|
+
raise T_REX_Segment_ParseError("Segment is not a valid TREX Scalar")
|
|
54
|
+
|
|
55
|
+
name, type_, value = matches.groups()
|
|
56
|
+
|
|
57
|
+
out = TREX_SimpleSegment(type=type_, value=value, segment_name=name)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def value_as_builtin_or_quantity_type(self) -> datetime|bool|str|PydanticUncertainQuantity:
|
|
62
|
+
return _value_as_builtin_or_quantity(self.value, self.type)
|
|
63
|
+
|
|
64
|
+
def as_trex_segment_str(self, segment_name) -> str:
|
|
65
|
+
return f'{segment_name}${self.type}:{self.value}'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TREX_Table(TREX_Segment):
|
|
69
|
+
col_names: list[str]
|
|
70
|
+
col_types: list[str]
|
|
71
|
+
data: list[list[str]]
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def from_trex_segmentstring( segment_str:str):
|
|
75
|
+
matches = re_table_pattern.match(segment_str)
|
|
76
|
+
if not matches:
|
|
77
|
+
raise T_REX_Segment_ParseError(f"Segment is not a valid TREX table: {segment_str}")
|
|
78
|
+
name, header, body = matches.groups()
|
|
79
|
+
|
|
80
|
+
column_heads = [re_col_head_pattern.match(colhead).groups() for colhead in header.split(':')]
|
|
81
|
+
col_names = [ch[0] for ch in column_heads]
|
|
82
|
+
col_types = [ch[1] for ch in column_heads]
|
|
83
|
+
|
|
84
|
+
data = [row.split(':') for row in body.split('::') ]
|
|
85
|
+
|
|
86
|
+
out = TREX_Table(col_names=col_names, col_types=col_types, data=data, segment_name=name)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
def n_rows(self) -> int:
|
|
90
|
+
return len(self.data)
|
|
91
|
+
|
|
92
|
+
def n_cols(self) -> int:
|
|
93
|
+
return len(self.col_names)
|
|
94
|
+
|
|
95
|
+
def row_data(self, row:int) -> list:
|
|
96
|
+
out = [_value_as_builtin_or_quantity(element, self.col_types[i]) for i, element in enumerate(self.data)]
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
def col_data(self, col:str|int) -> list:
|
|
100
|
+
col_index = self._get_col_index(col)
|
|
101
|
+
type = self.col_types[col_index]
|
|
102
|
+
out = [_value_as_builtin_or_quantity(row[col_index],type) for row in self.data]
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
def cell_data(self, row:int, col:str|int):
|
|
106
|
+
try:
|
|
107
|
+
col_index = self._get_col_index(col)
|
|
108
|
+
value = self.data[row][col_index]
|
|
109
|
+
type = self.col_types[col_index]
|
|
110
|
+
except ValueError:
|
|
111
|
+
logging.warning(f"row {row}, column {col} not found")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
return _value_as_builtin_or_quantity(value, type)
|
|
115
|
+
|
|
116
|
+
def _get_col_index(self, col:str|int):
|
|
117
|
+
if isinstance(col, str):
|
|
118
|
+
col_index = self.col_names.index(col)
|
|
119
|
+
elif isinstance(col, int):
|
|
120
|
+
col_index = col
|
|
121
|
+
else:
|
|
122
|
+
raise TypeError(f"Column must be specified as string or int: {col.__name__}")
|
|
123
|
+
return col_index
|
|
124
|
+
|
|
125
|
+
def as_trex_segment_str(self, name):
|
|
126
|
+
header = ':'.join([f'{el[0]}${el[1]}' for el in zip(self.col_names, self.col_types)])
|
|
127
|
+
date_rows = list()
|
|
128
|
+
for r in self.data:
|
|
129
|
+
row = ':'.join([str(cell) for cell in r])
|
|
130
|
+
date_rows.append(row)
|
|
131
|
+
data = '::'.join(date_rows)
|
|
132
|
+
s = f'{name}$${header}::{data}'
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TREX(Extension, BaseModel):
|
|
138
|
+
name_:str
|
|
139
|
+
segments: dict[str,TREX_Segment]
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def name(self)->str:
|
|
143
|
+
return self.name_
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def type(self)->str:
|
|
147
|
+
return 'TREX'
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def data(self)->str:
|
|
151
|
+
seg_strings = list()
|
|
152
|
+
for s_name, s in self.segments.items():
|
|
153
|
+
seg_strings.append(s.as_trex_segment_str(s_name))
|
|
154
|
+
s_out = '+'.join(seg_strings)
|
|
155
|
+
return s_out
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def from_spec_fields(name, type, data):
|
|
159
|
+
if type != 'TREX':
|
|
160
|
+
logging.warning(f'Type {name} was given, but this extension should only be used with type "TREX". Will try to parse data as TREX')
|
|
161
|
+
|
|
162
|
+
if not data:
|
|
163
|
+
raise ValueError(f'T-REX must be a string of non zero length')
|
|
164
|
+
|
|
165
|
+
trex_str = data
|
|
166
|
+
|
|
167
|
+
# remove extension indicator. Precaution in case it is not done yet
|
|
168
|
+
if trex_str[0]=="*":
|
|
169
|
+
trex_str=trex_str[1:-1]
|
|
170
|
+
# remove line breaks. for editing T-REXes it's more convenient to have them in, so one never knows
|
|
171
|
+
trex_str = re.sub(r"\s+", "", trex_str)
|
|
172
|
+
|
|
173
|
+
segment_strings = trex_str.split('+')
|
|
174
|
+
out_segments = dict()
|
|
175
|
+
for s in segment_strings:
|
|
176
|
+
# there are only two valid options. The segment is a scalar or a table.
|
|
177
|
+
# Constructors do the parsing anyways and raise exceptions if invalid data
|
|
178
|
+
# try both options and then let it fail
|
|
179
|
+
try:
|
|
180
|
+
segment = TREX_SimpleSegment.from_trex_segmentstring(s)
|
|
181
|
+
except T_REX_Segment_ParseError:
|
|
182
|
+
segment = TREX_Table.from_trex_segmentstring(s)
|
|
183
|
+
out_segments[segment.segment_name] = segment
|
|
184
|
+
|
|
185
|
+
return TREX(name_=name, segments=out_segments)
|
|
186
|
+
|
|
187
|
+
def get_segment(self, segment_id:str) -> TREX_Segment:
|
|
188
|
+
return self.segments.get(segment_id)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TREX_Struct(TREX_Segment):
|
|
194
|
+
"""Struct is a special interpretation of a T-REX Table with one row"""
|
|
195
|
+
wrapped_table:TREX_Table
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def segment_name_(self):
|
|
199
|
+
return self.wrapped_table.segment_name
|
|
200
|
+
|
|
201
|
+
@field_validator('wrapped_table')
|
|
202
|
+
def validate_table(table):
|
|
203
|
+
if len(table.data) != 1:
|
|
204
|
+
raise ValidationError("Too many input rows. Struct can only have one row")
|
|
205
|
+
return table
|
|
206
|
+
|
|
207
|
+
def get(self, key):
|
|
208
|
+
return self.wrapped_table.cell_data(0, key)
|
|
209
|
+
|
|
210
|
+
def keys(self):
|
|
211
|
+
return self.wrapped_table.col_names
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _to_datetime(trex_datetime):
|
|
217
|
+
try:
|
|
218
|
+
# return datetime.fromisoformat(trex_datetime) # should work with python 3.11
|
|
219
|
+
return datetime.strptime(trex_datetime, TREX_DATEFORMAT)
|
|
220
|
+
except (ValueError , TypeError) as e:
|
|
221
|
+
try:
|
|
222
|
+
return datetime.strptime(trex_datetime, TREX_TIMEFORMAT)
|
|
223
|
+
except (ValueError, TypeError):
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def _value_as_builtin_or_quantity(v:str|list[str], type:str) -> datetime|bool|str|PydanticUncertainQuantity:
|
|
227
|
+
match type:
|
|
228
|
+
case 'T.D':
|
|
229
|
+
return _to_datetime(v)
|
|
230
|
+
case 'T.B':
|
|
231
|
+
return v == 'T' or bool(v)
|
|
232
|
+
case 'T.A':
|
|
233
|
+
return v
|
|
234
|
+
case 'T.X':
|
|
235
|
+
raise NotImplementedError("Base36 encoded T-REX segment not implemented")
|
|
236
|
+
case 'E':
|
|
237
|
+
return v
|
|
238
|
+
case _:
|
|
239
|
+
return quantity_from_UN_CEFACT(v, type)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from .data_model import TREX, T_REX_Segment_ParseError, TREX_SimpleSegment, TREX_Table
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def from_trex_string(trex_str, enforce_type=True) -> TREX:
|
|
8
|
+
if not trex_str:
|
|
9
|
+
raise ValueError(f'T-REX must be a string of non zero length')
|
|
10
|
+
|
|
11
|
+
# remove extension indicator. Precaution in case it is not done yet
|
|
12
|
+
if trex_str[0]=="*":
|
|
13
|
+
trex_str=trex_str[1:-1]
|
|
14
|
+
# remove line breaks. for editing T-REXes it's more convenient to have them in, so one never knows
|
|
15
|
+
trex_str = trex_str.replace('\n','')
|
|
16
|
+
|
|
17
|
+
d = re.match('((?P<name>.+)\$(?P<type>.+)/)?(?P<data>.+)', trex_str).groupdict()
|
|
18
|
+
if not d:
|
|
19
|
+
raise ValueError('TREX is invalid.')
|
|
20
|
+
type = d.get('type')
|
|
21
|
+
name = d.get('name')
|
|
22
|
+
data = d.get('data')
|
|
23
|
+
|
|
24
|
+
if not type:
|
|
25
|
+
logging.warning('No type given. Assume its trex')
|
|
26
|
+
elif type != 'TREX' and enforce_type:
|
|
27
|
+
logging.error(f'Extension type {type} is not TREX. Aborting')
|
|
28
|
+
raise ValueError(f'Extension type {type} is not TREX.')
|
|
29
|
+
else:
|
|
30
|
+
logging.warning('Extension type {type} is not TREX. Try anyways')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
segment_strings = data.split('+')
|
|
34
|
+
out_segments = dict()
|
|
35
|
+
for s in segment_strings:
|
|
36
|
+
# there are only two valid options. The segment is a scalar or a table.
|
|
37
|
+
# Constructors do the parsing anyways and raise exceptions if invalid data
|
|
38
|
+
# try both options and then let it fail
|
|
39
|
+
try:
|
|
40
|
+
segment = TREX_SimpleSegment.from_trex_segmentstring(s)
|
|
41
|
+
except T_REX_Segment_ParseError:
|
|
42
|
+
segment = TREX_Table.from_trex_segmentstring(s)
|
|
43
|
+
out_segments[segment.segment_name] = segment
|
|
44
|
+
trex = TREX(name_= name, segments=out_segments)
|
|
45
|
+
trex._trex_str = trex_str
|
|
46
|
+
return trex
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from math import floor, log10, pow
|
|
2
|
+
|
|
3
|
+
def to_significant_digits_str(x:int|float, uncertainty:float|int) -> str:
|
|
4
|
+
if uncertainty == None:
|
|
5
|
+
if isinstance(x, float):
|
|
6
|
+
Warning(f'Uncertainty was given as none. Returning unrounded number')
|
|
7
|
+
return str(x)
|
|
8
|
+
else:
|
|
9
|
+
uncertainty = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
log_least_significant_digit = floor(log10(uncertainty))
|
|
13
|
+
digits = -log_least_significant_digit
|
|
14
|
+
|
|
15
|
+
x_significant = round(x, digits)
|
|
16
|
+
|
|
17
|
+
if digits <= 0:
|
|
18
|
+
return str(int(x_significant))
|
|
19
|
+
else:
|
|
20
|
+
return str(x_significant)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
print(to_significant_digits_str(111111.1111111, 1000))
|
|
26
|
+
print(to_significant_digits_str(111111.1111111, 100))
|
|
27
|
+
print(to_significant_digits_str(111111.1111111, 10))
|
|
28
|
+
print(to_significant_digits_str(111111.1111111, 1))
|
|
29
|
+
print(to_significant_digits_str(111111.1111111, 0.1))
|
|
30
|
+
print(to_significant_digits_str(111111.1111111, 0.01))
|
|
31
|
+
print(to_significant_digits_str(111111.1111111, 0.001))
|
|
32
|
+
print(to_significant_digits_str(111111.1111111, 0.0001))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
from typing_extensions import Annotated
|
|
3
|
+
from pydantic import BaseModel, AfterValidator
|
|
4
|
+
import quantities as pq
|
|
5
|
+
from quantities import Quantity, UnitQuantity, units, dimensionless
|
|
6
|
+
from .uncertainty import to_significant_digits_str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_unit(unit_name:str) -> str :
|
|
11
|
+
"""
|
|
12
|
+
Pydantic validator function for the unit.
|
|
13
|
+
Checks if the unit is a valid unit.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
unit (str): unit symbol, e.g. 'kg'
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
str: the input unit.
|
|
21
|
+
|
|
22
|
+
Errors:
|
|
23
|
+
raises an AssertionError if validation fails
|
|
24
|
+
"""
|
|
25
|
+
if hasattr(pq, unit_name):
|
|
26
|
+
return unit_name
|
|
27
|
+
else:
|
|
28
|
+
assert False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PydanticUncertainQuantity(BaseModel):
|
|
32
|
+
data:int|float
|
|
33
|
+
unit_name: Annotated[str, AfterValidator(validate_unit)]
|
|
34
|
+
unit_symbol: str
|
|
35
|
+
uncertainty:float|None=None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def for_display(self):
|
|
39
|
+
return self.__str__()
|
|
40
|
+
|
|
41
|
+
def as_strings(self):
|
|
42
|
+
unit_symbol = self.unit_symbol
|
|
43
|
+
if unit_symbol == "dimensionless":
|
|
44
|
+
unit_symbol = ""
|
|
45
|
+
s = ''
|
|
46
|
+
|
|
47
|
+
val_str = to_significant_digits_str(self.data, self.uncertainty)
|
|
48
|
+
return f"{val_str}", f"{unit_symbol}", f"{val_str} {unit_symbol}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __str__(self):
|
|
53
|
+
unit_symbol = self.unit_symbol
|
|
54
|
+
if unit_symbol == "dimensionless":
|
|
55
|
+
unit_symbol = ""
|
|
56
|
+
|
|
57
|
+
s = f"{to_significant_digits_str(self.data, self.uncertainty)} {unit_symbol}"
|
|
58
|
+
return s
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
unit_map = [
|
|
62
|
+
('MGM', units.milligram),
|
|
63
|
+
('CEL', units.celsius),
|
|
64
|
+
('LTR', units.liter),
|
|
65
|
+
('MLT', units.milliliter),
|
|
66
|
+
('GRM', units.gram),
|
|
67
|
+
('KGM', units.kilogram),
|
|
68
|
+
('C34', units.mole),
|
|
69
|
+
('D43',units.atomic_mass_unit),
|
|
70
|
+
('1', units.dimensionless),
|
|
71
|
+
('C62', units.dimensionless),
|
|
72
|
+
('BAR',units.bar),
|
|
73
|
+
('MBR',units.millibar),
|
|
74
|
+
('KBA',units.kilobar),
|
|
75
|
+
('RPM', units.rpm),
|
|
76
|
+
('HUR', units.hour),
|
|
77
|
+
('HTZ', units.hertz),
|
|
78
|
+
('KHZ', units.kilohertz),
|
|
79
|
+
('MHZ',units.megahertz),
|
|
80
|
+
('SEC', units.second),
|
|
81
|
+
('MIN', units.minute),
|
|
82
|
+
('URH', units.hour)
|
|
83
|
+
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def quantity_from_UN_CEFACT(value:str, unit_UN_CEFACT) -> PydanticUncertainQuantity:
|
|
88
|
+
"""
|
|
89
|
+
Maps units from https://unece.org/trade/documents/revision-17-annexes-i-iii
|
|
90
|
+
to an object of the quantities library https://python-quantities.readthedocs.io/en/latest/index.html
|
|
91
|
+
"""
|
|
92
|
+
# cast to numeric type. try int first, which will fail if string has no decimals.
|
|
93
|
+
# nothing to worry yet: try floast next. if that fails the input was not a str representation of a number
|
|
94
|
+
try:
|
|
95
|
+
value_out = int(value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
try:
|
|
98
|
+
value_out = float(value)
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
raise Exception(f'Input {value} is not a str representation of a number') from e
|
|
101
|
+
|
|
102
|
+
d = {um[0]: um[1] for um in unit_map}
|
|
103
|
+
|
|
104
|
+
unit = d.get(unit_UN_CEFACT)
|
|
105
|
+
if not unit:
|
|
106
|
+
raise NotImplementedError(f"lookup for unit {unit} not implemented")
|
|
107
|
+
out = PydanticUncertainQuantity(data=value_out, unit_name=unit.name, unit_symbol=unit.symbol)
|
|
108
|
+
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def quantity_to_UN_CEFACT(value:PydanticUncertainQuantity ) -> Tuple[int|float, str]:
|
|
113
|
+
d = {um[1].symbol: um[0] for um in unit_map}
|
|
114
|
+
|
|
115
|
+
unit_un_cefact = d.get(value.unit_symbol)
|
|
116
|
+
if not unit_un_cefact:
|
|
117
|
+
raise NotImplementedError(f"lookup for unit {value.unit_symbol} not implemented")
|
|
118
|
+
return value.data, unit_un_cefact
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
labfreed/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labfreed
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Python implementation of LabFREED building blocks
|
|
5
|
+
Author-email: Reto Thürer <reto.thuerer@gmail.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
|
|
13
|
+
# LabFREED for Python
|
|
14
|
+
|
|
15
|
+
A python implementation of [LabFREED](www.labfreed.com).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
labfreed/__init__.py,sha256=ToYkbhG_8yX4YOS9mieyjvGLG2M8raraqRFDbU71d3A,87
|
|
2
|
+
labfreed/DisplayNameExtension/DisplayNameExtension.py,sha256=FlT53u1EntpsLmho6GZtgIWBZBNWkl9STxzJBvojR6M,1033
|
|
3
|
+
labfreed/DisplayNameExtension/base36.py,sha256=2lwmEMWm8qrFJkcrP-nMPwS0eCm2THhCJ3Vk-TdGQg0,2455
|
|
4
|
+
labfreed/PAC_CAT/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
|
|
5
|
+
labfreed/PAC_CAT/data_model.py,sha256=y1cTFZknObi5XOk4aqfgeJv6_G8Rjd9mxTRwNCQR0M4,4311
|
|
6
|
+
labfreed/PAC_ID/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
labfreed/PAC_ID/data_model.py,sha256=oVVqspdZ4B_C4V1rOmcde2_EqWUpDJc6vDUnX4Z1ZPw,3123
|
|
8
|
+
labfreed/PAC_ID/parse.py,sha256=ZmCpuht7o6RgDZCweF7Y0k7QFTd0gLtUpmjFsIlz_wA,4668
|
|
9
|
+
labfreed/PAC_ID/serialize.py,sha256=ujI4SFs-TsFb4FVU2hzwardEWu8-2MYjewE0IyC1yok,1871
|
|
10
|
+
labfreed/TREXExtension/data_model.py,sha256=eT4KyQklTO6m-wA28KyJ8wzT8ONhG3fOB3JU6b19ScY,8011
|
|
11
|
+
labfreed/TREXExtension/parse.py,sha256=Y04UK1KlMLG9tE_d7cQOiAJpm8Zh49UoJYjY7Lfqa4Y,1812
|
|
12
|
+
labfreed/TREXExtension/uncertainty.py,sha256=l3WxrLnWTQYfX28gFisXwDcVPvT8bCAd4q6Xl02dRdE,1117
|
|
13
|
+
labfreed/TREXExtension/unit_utilities.py,sha256=ZhivJnEXMvz7HxLJjI1oJ8y_pcPxW2abKkouiqTo4I4,3775
|
|
14
|
+
labfreed-0.0.3.dist-info/licenses/LICENSE,sha256=gHFOv9FRKHxO8cInP3YXyPoJnuNeqrvcHjaE_wPSsQ8,1100
|
|
15
|
+
labfreed-0.0.3.dist-info/WHEEL,sha256=_2ozNFCLWc93bK4WKHCO-eDUENDlo-dgc9cU3qokYO4,82
|
|
16
|
+
labfreed-0.0.3.dist-info/METADATA,sha256=EAt_IEv09C97i4lu65HHBCaWCdF6y4gnmZDbp7H7TaM,449
|
|
17
|
+
labfreed-0.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Reto Thürer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|