sysmlv2-units 1.0.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.
- sysmlv2_units/__init__.py +3 -0
- sysmlv2_units/units_helper.py +1103 -0
- sysmlv2_units-1.0.0.dist-info/METADATA +82 -0
- sysmlv2_units-1.0.0.dist-info/RECORD +7 -0
- sysmlv2_units-1.0.0.dist-info/WHEEL +5 -0
- sysmlv2_units-1.0.0.dist-info/licenses/LICENSE +21 -0
- sysmlv2_units-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import syside
|
|
3
|
+
import logging
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from typing import Tuple, Optional, Union, Dict, List, Type
|
|
6
|
+
from pint import Unit, Quantity, get_application_registry, UndefinedUnitError, register_unit_format
|
|
7
|
+
|
|
8
|
+
__all__ = ['SysMLUnitsHelper', 'ureg', 'Unit', 'Quantity', 'UndefinedUnitError']
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger('sysml.units')
|
|
11
|
+
|
|
12
|
+
ureg = get_application_registry()
|
|
13
|
+
_UNKNOWN_UNIT = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SysMLUnitsHelper:
|
|
17
|
+
"""
|
|
18
|
+
Class with functions for dealing with units and quantities (value + units) in SysML v2.
|
|
19
|
+
On the Python-side, it uses the [pint](https://pint.readthedocs.io/) package.
|
|
20
|
+
|
|
21
|
+
Supports units from the SysML units library and units expressions (e.g. defining your own units by combining
|
|
22
|
+
existing units through operations like division, multiplication, exponentiation, etc.).
|
|
23
|
+
|
|
24
|
+
Raises an `UndefinedUnitError` if any unit parsing or conversion to/from SysML fails.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
dimensionless_units_pint = ureg.dimensionless
|
|
28
|
+
dimensionless_units_sysml_key = 'MeasurementReferences::one'
|
|
29
|
+
|
|
30
|
+
_binary_operators = {
|
|
31
|
+
syside.Operator.Divide: '/',
|
|
32
|
+
syside.Operator.Multiply: '*',
|
|
33
|
+
syside.Operator.ExponentStar: '**',
|
|
34
|
+
syside.Operator.ExponentCaret: '^',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_sysml_alias_map: Dict[str, List[str]] = {} # units attr key --> list of alias names
|
|
38
|
+
_sysml_units_map: Dict[str, str] = {} # alias name --> units attr key
|
|
39
|
+
|
|
40
|
+
_pint_sysml_units_map = {}
|
|
41
|
+
_sysml_pint_units_map = {}
|
|
42
|
+
|
|
43
|
+
_sysml_quantities_units_map: Dict[str, str] = {} # quantity attr key --> units attr key
|
|
44
|
+
|
|
45
|
+
_explicit_units_map = [ # Pint -> SysML
|
|
46
|
+
('degC', '°C_abs'),
|
|
47
|
+
('delta_degC', '°C'),
|
|
48
|
+
('degF', '°F_abs'),
|
|
49
|
+
('delta_degF', '°F'),
|
|
50
|
+
('kph', 'km/h'),
|
|
51
|
+
('mph', 'mph'),
|
|
52
|
+
('VA', 'V⋅A'),
|
|
53
|
+
('Wh', 'W⋅h'),
|
|
54
|
+
('m/s²', 'm/s²'),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
_units_remove_list = [ # Duplicate SysML units or otherwise wrongly-interpreted units
|
|
58
|
+
'var', # V*A
|
|
59
|
+
'octet', # byte
|
|
60
|
+
'octet per second', # byte per second
|
|
61
|
+
'm²⋅A', # A*m**2
|
|
62
|
+
'ua', # astronomical unit, conflict with microyear in pint
|
|
63
|
+
'J⋅s⁻¹', # J/s
|
|
64
|
+
'm⋅s⁻¹', # m/s
|
|
65
|
+
'mol⋅kg⁻¹', # mol/kg
|
|
66
|
+
'mol⋅m⁻³', # mol/m3
|
|
67
|
+
'ft⋅lbf',
|
|
68
|
+
'kg⁻¹⋅s⋅A',
|
|
69
|
+
'lbf⋅in/in',
|
|
70
|
+
'circular mil',
|
|
71
|
+
'unit pole',
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
do_log = False
|
|
75
|
+
|
|
76
|
+
def __init__(self, model: syside.Model):
|
|
77
|
+
doc_namespace_map = {}
|
|
78
|
+
|
|
79
|
+
# Load some base elements from the SysML standard library
|
|
80
|
+
self.unit_base_def = self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
81
|
+
'MeasurementReferences::MeasurementUnit', syside.AttributeDefinition, env=True)
|
|
82
|
+
self.scale_base_def = self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
83
|
+
'MeasurementReferences::MeasurementScale', syside.AttributeDefinition, env=True)
|
|
84
|
+
|
|
85
|
+
self.quantity_value_base_def = self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
86
|
+
'Quantities::TensorQuantityValue', syside.AttributeDefinition, env=True)
|
|
87
|
+
|
|
88
|
+
self.dimensionless_units_def_sysml = self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
89
|
+
'MeasurementReferences::DimensionOneUnit', syside.AttributeDefinition, env=True)
|
|
90
|
+
self.dimensionless_units_sysml = self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
91
|
+
'MeasurementReferences::one', syside.AttributeUsage, env=True)
|
|
92
|
+
self.__class__.dimensionless_units_sysml_key = self._sysml_attr_key(self.dimensionless_units_sysml)
|
|
93
|
+
|
|
94
|
+
# Load the units libraries and initialize unit mapping caches
|
|
95
|
+
self.units_libraries = [
|
|
96
|
+
self._search_namespace(model, doc_namespace_map, 'SI', env=True),
|
|
97
|
+
self._search_namespace(model, doc_namespace_map, 'USCustomaryUnits', env=True),
|
|
98
|
+
]
|
|
99
|
+
sysml_alias_map, sysml_units_map, self._sysml_obj_map = self._get_sysml_units_map()
|
|
100
|
+
self._init_mappings(sysml_units_map, sysml_alias_map)
|
|
101
|
+
|
|
102
|
+
# Initialize mapping from quantity value types to default units
|
|
103
|
+
if len(self.__class__._sysml_quantities_units_map) == 0:
|
|
104
|
+
isq = self._get_element_by_qualified_name(
|
|
105
|
+
model, doc_namespace_map, 'ISQBase::isq', syside.AttributeUsage, env=True)
|
|
106
|
+
si = self._get_element_by_qualified_name(
|
|
107
|
+
model, doc_namespace_map, 'SI::si', syside.AttributeUsage, env=True)
|
|
108
|
+
|
|
109
|
+
dimensionless_quantities = [
|
|
110
|
+
self._get_element_by_qualified_name(model, doc_namespace_map,
|
|
111
|
+
'MeasurementReferences::DimensionOneValue', syside.AttributeDefinition, env=True),
|
|
112
|
+
self.dimensionless_units_def_sysml,
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
self.__class__._sysml_quantities_units_map = self._map_base_quantities(isq, si, dimensionless_quantities)
|
|
116
|
+
|
|
117
|
+
# Check if all base elements were found
|
|
118
|
+
if any([prop is None for prop in [
|
|
119
|
+
self.unit_base_def,
|
|
120
|
+
self.scale_base_def,
|
|
121
|
+
self.dimensionless_units_sysml,
|
|
122
|
+
]+self.units_libraries]):
|
|
123
|
+
raise RuntimeError('Not all units helper elements found!')
|
|
124
|
+
|
|
125
|
+
###################################################################
|
|
126
|
+
### SysML to Python (pint) conversion functions (SysML getters) ###
|
|
127
|
+
###################################################################
|
|
128
|
+
|
|
129
|
+
def get_quantity(self, feature: Union[syside.Feature, syside.Expression], raise_if_unknown_unit=True) -> Quantity:
|
|
130
|
+
"""
|
|
131
|
+
Parses a feature value and returns a quantity (value + units).
|
|
132
|
+
Raises a ValueError if the value is not numerical.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# Get units if set
|
|
136
|
+
value_expression, is_negation, units, _ = self._get_feature_value_units(
|
|
137
|
+
feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
138
|
+
|
|
139
|
+
# Get the value
|
|
140
|
+
if value_expression is None:
|
|
141
|
+
raise ValueError(f'No feature value set on feature: {feature}')
|
|
142
|
+
|
|
143
|
+
value = self._simple_parse_value(value_expression)
|
|
144
|
+
|
|
145
|
+
if is_negation:
|
|
146
|
+
value = -value
|
|
147
|
+
|
|
148
|
+
# Return a Pint quantity object
|
|
149
|
+
return self.quantity(value, units)
|
|
150
|
+
|
|
151
|
+
def get_units(self, feature: Union[syside.Feature, syside.Expression], raise_if_unknown_unit=True) \
|
|
152
|
+
-> Tuple[Optional[Unit], Optional[syside.AttributeUsage]]:
|
|
153
|
+
"""
|
|
154
|
+
Gets the Pint units as set as a feature value or as part of a quantity expression.
|
|
155
|
+
If none found but the feature derives from a quantity value base type, the preferred associated units are
|
|
156
|
+
returned.
|
|
157
|
+
|
|
158
|
+
Also returns the original units attribute that was set (if applicable).
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
# Check if the feature has a value
|
|
162
|
+
if (isinstance(feature, syside.Feature) and not isinstance(feature, syside.Expression)
|
|
163
|
+
and feature.feature_value is None):
|
|
164
|
+
|
|
165
|
+
# If not, check if the feature itself is a unit
|
|
166
|
+
if isinstance(feature, syside.AttributeUsage):
|
|
167
|
+
# Check if the value is the dimensionless unit
|
|
168
|
+
if feature == self.dimensionless_units_sysml:
|
|
169
|
+
return None, None
|
|
170
|
+
|
|
171
|
+
# Check if the feature derives from a QuantityValue
|
|
172
|
+
if self.is_typed_by_quantity_value(feature):
|
|
173
|
+
preferred_units = self.get_quantity_value_units(feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
174
|
+
return preferred_units, None
|
|
175
|
+
|
|
176
|
+
# Directly try to parse a unit set as the feature value
|
|
177
|
+
parsed_units, units_attr = self._parse_units_attr(feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
178
|
+
if parsed_units is not None:
|
|
179
|
+
return parsed_units, units_attr
|
|
180
|
+
|
|
181
|
+
raise ValueError(f'No feature value set on feature: {feature}')
|
|
182
|
+
|
|
183
|
+
# Parse the unit that is part of a quantity
|
|
184
|
+
_, _, units, units_attr = self._get_feature_value_units(feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
185
|
+
return units, units_attr
|
|
186
|
+
|
|
187
|
+
def _get_feature_value_units(self, feature: Union[syside.Feature, syside.Expression], raise_if_unknown_unit=True) \
|
|
188
|
+
-> Tuple[Optional[syside.Expression], bool, Optional[Unit], Optional[syside.AttributeUsage]]:
|
|
189
|
+
"""
|
|
190
|
+
Parses a feature value and returns the (pint) units if set.
|
|
191
|
+
Returns the (contained) feature that contains the value, so that should still be parsed.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
# Get the value expression to parse
|
|
195
|
+
if isinstance(feature, syside.Expression):
|
|
196
|
+
value_expression = feature
|
|
197
|
+
else:
|
|
198
|
+
if feature.feature_value is None:
|
|
199
|
+
return None, False, None, None
|
|
200
|
+
|
|
201
|
+
value_expression: Optional[syside.Expression] = feature.feature_value.value
|
|
202
|
+
|
|
203
|
+
# Check if it is a negation
|
|
204
|
+
is_negation = False
|
|
205
|
+
if (isinstance(value_expression, syside.OperatorExpression) and
|
|
206
|
+
value_expression.operator == syside.Operator.Minus):
|
|
207
|
+
|
|
208
|
+
value_feature = value_expression.children.elements[0]
|
|
209
|
+
value_expression = value_feature.feature_value.value
|
|
210
|
+
is_negation = True
|
|
211
|
+
|
|
212
|
+
# Check if it is a quantity expression: <child1>[<child2>]
|
|
213
|
+
if (isinstance(value_expression, syside.OperatorExpression)
|
|
214
|
+
and value_expression.operator == syside.Operator.Quantity
|
|
215
|
+
and len(value_expression.children) == 2):
|
|
216
|
+
|
|
217
|
+
# Extract the child parameters of the expression
|
|
218
|
+
units_feature: syside.Feature
|
|
219
|
+
value_feature, units_feature = value_expression.children.elements
|
|
220
|
+
|
|
221
|
+
value_expression = value_feature.feature_value.value
|
|
222
|
+
|
|
223
|
+
# Parse the units from the second parameter
|
|
224
|
+
units, units_attr = self._parse_units_feature(units_feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
225
|
+
|
|
226
|
+
# Otherwise try to directly parse the value expression as units
|
|
227
|
+
else:
|
|
228
|
+
units, units_attr = self._parse_units_feature(value_expression, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
229
|
+
|
|
230
|
+
if units is not None and isinstance(units, Unit):
|
|
231
|
+
value_expression = None
|
|
232
|
+
else:
|
|
233
|
+
units = units_attr = None
|
|
234
|
+
|
|
235
|
+
return value_expression, is_negation, units, units_attr
|
|
236
|
+
|
|
237
|
+
def _parse_units_feature(self, units_feature: Union[syside.Feature, syside.Expression],
|
|
238
|
+
raise_if_unknown_unit=True) \
|
|
239
|
+
-> Tuple[Union[Optional[Unit], int], Optional[syside.AttributeUsage]]:
|
|
240
|
+
"""
|
|
241
|
+
Parses a feature that defines the units.
|
|
242
|
+
Additionally, returns the units attribute that was set to define the unit if applicable.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# Get the expression to parse
|
|
246
|
+
if isinstance(units_feature, syside.Expression):
|
|
247
|
+
units_expression = units_feature
|
|
248
|
+
else:
|
|
249
|
+
units_expression = units_feature.feature_value.value
|
|
250
|
+
|
|
251
|
+
# Parse a referenced units attribute, e.g.: SI::m
|
|
252
|
+
if isinstance(units_expression, syside.FeatureReferenceExpression):
|
|
253
|
+
|
|
254
|
+
# Get the referenced units attribute
|
|
255
|
+
units_attr = units_expression.referent
|
|
256
|
+
if not isinstance(units_attr, syside.AttributeUsage):
|
|
257
|
+
return None, None
|
|
258
|
+
|
|
259
|
+
# Parse the units attr to Pint units
|
|
260
|
+
units, _ = self._parse_units_attr(units_attr, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
261
|
+
return units, units_attr
|
|
262
|
+
|
|
263
|
+
# Parse an exponent value (as part of parsing a units expression)
|
|
264
|
+
if (isinstance(units_expression, syside.LiteralInteger) or
|
|
265
|
+
(isinstance(units_expression, syside.OperatorExpression)
|
|
266
|
+
and units_expression.operator == syside.Operator.Minus)):
|
|
267
|
+
|
|
268
|
+
value = self._simple_parse_value(units_expression)
|
|
269
|
+
return value, None
|
|
270
|
+
|
|
271
|
+
# Recursively parse a units expression, e.g.: m*s^-1
|
|
272
|
+
if (isinstance(units_expression, syside.OperatorExpression) and
|
|
273
|
+
units_expression.operator in self._binary_operators and len(units_expression.children) == 2):
|
|
274
|
+
|
|
275
|
+
# Get the left and right side features and the operator str
|
|
276
|
+
left_side_feature, right_side_feature = units_expression.children.elements
|
|
277
|
+
operator_str = self._binary_operators[units_expression.operator]
|
|
278
|
+
|
|
279
|
+
# Parse the left and right side features into units or literal values
|
|
280
|
+
left_side_units, _ = self._parse_units_feature(
|
|
281
|
+
left_side_feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
282
|
+
right_side_units, _ = self._parse_units_feature(
|
|
283
|
+
right_side_feature, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
284
|
+
|
|
285
|
+
# Check for unknown or dimensionless units
|
|
286
|
+
if right_side_units is None:
|
|
287
|
+
return left_side_units, None
|
|
288
|
+
|
|
289
|
+
if left_side_units is None:
|
|
290
|
+
left_side_units = self.dimensionless_units_pint
|
|
291
|
+
|
|
292
|
+
# Parse Pint units
|
|
293
|
+
left_str = f'{left_side_units:~C}' if isinstance(left_side_units, Unit) else str(left_side_units)
|
|
294
|
+
right_str = f'{right_side_units:~C}' if isinstance(right_side_units, Unit) else str(right_side_units)
|
|
295
|
+
units = self.parse_python_units(' '.join([left_str, operator_str, right_str]))
|
|
296
|
+
return units, None
|
|
297
|
+
|
|
298
|
+
return None, None
|
|
299
|
+
|
|
300
|
+
def _parse_units_attr(self, units_attr: syside.AttributeUsage, raise_if_unknown_unit=True) \
|
|
301
|
+
-> Tuple[Optional[Unit], Optional[syside.AttributeUsage]]:
|
|
302
|
+
"""Parse a referred-to attribute, also trying chaining via value or subsetting."""
|
|
303
|
+
|
|
304
|
+
# Parse the units attr
|
|
305
|
+
try:
|
|
306
|
+
units = self.get_python_units(units_attr, raise_if_unknown_unit=True, cache_unknown=False)
|
|
307
|
+
return units, units_attr
|
|
308
|
+
|
|
309
|
+
except UndefinedUnitError:
|
|
310
|
+
|
|
311
|
+
# Try to parse a unit set as the value
|
|
312
|
+
if units_attr.feature_value is not None:
|
|
313
|
+
parsed_units, value_units_attr = self._parse_units_feature(
|
|
314
|
+
units_attr.feature_value.value, raise_if_unknown_unit=False)
|
|
315
|
+
if parsed_units is not None:
|
|
316
|
+
return parsed_units, value_units_attr
|
|
317
|
+
|
|
318
|
+
# Try to parse a unit by subsetting
|
|
319
|
+
for specialization, subset_el in units_attr.heritage:
|
|
320
|
+
if isinstance(specialization, syside.Subsetting) and isinstance(subset_el, syside.AttributeUsage):
|
|
321
|
+
parsed_units, _ = self._parse_units_attr(subset_el, raise_if_unknown_unit=False)
|
|
322
|
+
if parsed_units is not None:
|
|
323
|
+
return parsed_units, subset_el
|
|
324
|
+
|
|
325
|
+
if raise_if_unknown_unit:
|
|
326
|
+
raise
|
|
327
|
+
return None, None
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def _simple_parse_value(cls, expression: syside.Expression):
|
|
331
|
+
"""Parses int, real, inf, null (NaN) values, positive or negative."""
|
|
332
|
+
|
|
333
|
+
# Parse a list
|
|
334
|
+
if isinstance(expression, syside.OperatorExpression) and expression.operator == syside.Operator.Comma:
|
|
335
|
+
values = []
|
|
336
|
+
for child_feature in expression.children.elements:
|
|
337
|
+
if child_feature.feature_value and child_feature.feature_value.value:
|
|
338
|
+
child_value = child_feature.feature_value.value
|
|
339
|
+
values.append(cls._simple_parse_value(child_value))
|
|
340
|
+
return values
|
|
341
|
+
|
|
342
|
+
# Feature chain
|
|
343
|
+
if isinstance(expression, syside.FeatureChainExpression):
|
|
344
|
+
return expression.target_feature.feature_target
|
|
345
|
+
|
|
346
|
+
# Feature reference
|
|
347
|
+
if isinstance(expression, syside.FeatureReferenceExpression):
|
|
348
|
+
return expression.referent
|
|
349
|
+
|
|
350
|
+
# Check if it is a negation
|
|
351
|
+
is_negation = False
|
|
352
|
+
if isinstance(expression, syside.OperatorExpression) and expression.operator == syside.Operator.Minus:
|
|
353
|
+
|
|
354
|
+
value_feature = expression.children.elements[0]
|
|
355
|
+
expression = value_feature.feature_value.value
|
|
356
|
+
is_negation = True
|
|
357
|
+
|
|
358
|
+
# Parse numerical values
|
|
359
|
+
if isinstance(expression, (syside.LiteralInteger, syside.LiteralRational)):
|
|
360
|
+
value = expression.value
|
|
361
|
+
return -value if is_negation else value
|
|
362
|
+
|
|
363
|
+
# Parse infinity and null
|
|
364
|
+
if isinstance(expression, syside.NullExpression):
|
|
365
|
+
return math.nan
|
|
366
|
+
if isinstance(expression, syside.LiteralInfinity):
|
|
367
|
+
return -math.inf if is_negation else math.inf
|
|
368
|
+
|
|
369
|
+
raise ValueError(f'Could not parse simple expression: {expression}')
|
|
370
|
+
|
|
371
|
+
#######################################################################
|
|
372
|
+
### Python (pint/str) to SysML conversion functions (SysML setters) ###
|
|
373
|
+
#######################################################################
|
|
374
|
+
|
|
375
|
+
def set_quantity(self, feature: syside.Feature, quantity: Quantity, raise_if_unknown_unit=True):
|
|
376
|
+
"""Set the feature value to a quantity (value + units)."""
|
|
377
|
+
self.set_value_and_units(feature, quantity.magnitude, quantity.units,
|
|
378
|
+
raise_if_unknown_unit=raise_if_unknown_unit)
|
|
379
|
+
|
|
380
|
+
def set_value_and_units(self, feature: syside.Feature, value: float,
|
|
381
|
+
units: Union[Unit, str, syside.AttributeUsage] = None, raise_if_unknown_unit=True):
|
|
382
|
+
"""Same as set_quantity, but by supplying the value and units separately, also supports SysML units."""
|
|
383
|
+
|
|
384
|
+
# Set minus operator if needed
|
|
385
|
+
if value < 0:
|
|
386
|
+
feature = self._set_minus_operator(feature)
|
|
387
|
+
value = -value
|
|
388
|
+
|
|
389
|
+
# Set units if needed
|
|
390
|
+
value_feature = self._set_feature_value_units(
|
|
391
|
+
feature, units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
392
|
+
|
|
393
|
+
# Set the value
|
|
394
|
+
self._set_simple_value(value_feature, value)
|
|
395
|
+
|
|
396
|
+
def set_units(self, feature: syside.Feature, units: Union[Unit, str, syside.AttributeUsage],
|
|
397
|
+
raise_if_unknown_unit=True):
|
|
398
|
+
"""Set the feature value to a unit."""
|
|
399
|
+
|
|
400
|
+
# Parse units if needed
|
|
401
|
+
if isinstance(units, str):
|
|
402
|
+
units = self.parse_python_units(units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
403
|
+
|
|
404
|
+
# Get the associated SysML units attribute
|
|
405
|
+
if isinstance(units, syside.AttributeUsage):
|
|
406
|
+
units_attr = units
|
|
407
|
+
else:
|
|
408
|
+
units_attr = self.get_sysml_units(units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
409
|
+
|
|
410
|
+
# Create and set the reference expression
|
|
411
|
+
reference_expression: syside.FeatureReferenceExpression
|
|
412
|
+
_, reference_expression = feature.feature_value_member.set_member_element(
|
|
413
|
+
syside.FeatureReferenceExpression)
|
|
414
|
+
|
|
415
|
+
reference_expression.referent_member.set_member_element(units_attr)
|
|
416
|
+
|
|
417
|
+
def _set_feature_value_units(self, feature: syside.Feature, units: Union[Unit, str, syside.AttributeUsage] = None,
|
|
418
|
+
raise_if_unknown_unit=True) -> syside.Feature:
|
|
419
|
+
"""
|
|
420
|
+
Optionally create a new quantity expression if a unit should be set.
|
|
421
|
+
Returns the feature that should get the actual value (not set yet).
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
# Parse units if needed
|
|
425
|
+
if units and isinstance(units, syside.AttributeUsage):
|
|
426
|
+
|
|
427
|
+
# Check if dimensionless
|
|
428
|
+
if units == self.dimensionless_units_sysml:
|
|
429
|
+
return feature
|
|
430
|
+
|
|
431
|
+
else:
|
|
432
|
+
units = self.parse_python_units(units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
433
|
+
|
|
434
|
+
# Check if dimensionless
|
|
435
|
+
if not units:
|
|
436
|
+
return feature
|
|
437
|
+
|
|
438
|
+
# Create a new Quantity expression
|
|
439
|
+
quantity_expression: syside.OperatorExpression
|
|
440
|
+
_, quantity_expression = feature.feature_value_member.set_member_element(syside.OperatorExpression)
|
|
441
|
+
quantity_expression.operator = syside.ExplicitOperator.Quantity
|
|
442
|
+
|
|
443
|
+
_, value_feature = quantity_expression.children.append(syside.ParameterMembership, syside.Feature)
|
|
444
|
+
|
|
445
|
+
# Set the units
|
|
446
|
+
units_feature: syside.Feature
|
|
447
|
+
_, units_feature = quantity_expression.children.append(syside.ParameterMembership, syside.Feature)
|
|
448
|
+
|
|
449
|
+
self.set_units(units_feature, units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
450
|
+
|
|
451
|
+
return value_feature
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _set_minus_operator(feature: syside.Feature) -> syside.Feature:
|
|
455
|
+
"""Create a new minus operator expression and return the value-containing feature."""
|
|
456
|
+
|
|
457
|
+
expression: syside.OperatorExpression
|
|
458
|
+
_, expression = feature.feature_value_member.set_member_element(syside.OperatorExpression)
|
|
459
|
+
expression.operator = syside.ExplicitOperator.Minus
|
|
460
|
+
|
|
461
|
+
_, value_feature = expression.children.append(syside.ParameterMembership, syside.Feature)
|
|
462
|
+
return value_feature
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def _set_simple_value(cls, feature: syside.Feature, value):
|
|
466
|
+
"""Sets positive or negative int, real, inf or null (NaN) values."""
|
|
467
|
+
|
|
468
|
+
# Create minus operator if needed
|
|
469
|
+
value_feature = feature
|
|
470
|
+
if value < 0:
|
|
471
|
+
value_feature = cls._set_minus_operator(value_feature)
|
|
472
|
+
value = -value
|
|
473
|
+
|
|
474
|
+
# Set infinity value
|
|
475
|
+
if math.isinf(value):
|
|
476
|
+
value_feature.feature_value_member.set_member_element(syside.LiteralInfinity)
|
|
477
|
+
|
|
478
|
+
# Set NaN value (null)
|
|
479
|
+
elif value is None or math.isnan(value):
|
|
480
|
+
value_feature.feature_value_member.set_member_element(syside.NullExpression)
|
|
481
|
+
|
|
482
|
+
# Set integer value
|
|
483
|
+
elif isinstance(value, int):
|
|
484
|
+
_, literal = value_feature.feature_value_member.set_member_element(syside.LiteralInteger)
|
|
485
|
+
literal.value = value
|
|
486
|
+
|
|
487
|
+
# Set float (rational) value
|
|
488
|
+
elif isinstance(value, float):
|
|
489
|
+
_, literal = value_feature.feature_value_member.set_member_element(syside.LiteralRational)
|
|
490
|
+
literal.value = value
|
|
491
|
+
|
|
492
|
+
else:
|
|
493
|
+
raise ValueError(f'Could not set simple value: {value}')
|
|
494
|
+
|
|
495
|
+
################################
|
|
496
|
+
### SysML printing functions ###
|
|
497
|
+
################################
|
|
498
|
+
|
|
499
|
+
@classmethod
|
|
500
|
+
def to_text(cls, element: syside.Element, printer_config: syside.PrinterConfig = None):
|
|
501
|
+
"""
|
|
502
|
+
Render an element as SysML v2 textual notation, using the units ReferencePrinter so that units are printed
|
|
503
|
+
using their short names.
|
|
504
|
+
|
|
505
|
+
Note: this only works if a UnitsHelper has been instantiated at least once (to initialize the caches).
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
# Get printer config
|
|
509
|
+
if printer_config is None:
|
|
510
|
+
printer_config = syside.PrinterConfig(line_width=120, tab_width=4)
|
|
511
|
+
|
|
512
|
+
# Get the reference printer
|
|
513
|
+
reference_printer = cls.get_reference_printer()
|
|
514
|
+
|
|
515
|
+
# Create the printer and print the model
|
|
516
|
+
printer = syside.ModelPrinter.sysml(reference_printer=reference_printer)
|
|
517
|
+
sysml_text = syside.pprint(element, printer, printer_config)
|
|
518
|
+
|
|
519
|
+
return sysml_text
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def get_reference_printer(cls) -> syside.ReferencePrinter:
|
|
523
|
+
"""Returns a reference printer that prints references to units using their short name."""
|
|
524
|
+
alias_map = cls._sysml_alias_map
|
|
525
|
+
|
|
526
|
+
def get_name_pref(target: syside.Element, referent: syside.Element) -> syside.NamePreference:
|
|
527
|
+
# Check if the target is a unit
|
|
528
|
+
if target.__class__ == syside.AttributeUsage:
|
|
529
|
+
units_attr_key = cls._sysml_attr_key(target)
|
|
530
|
+
if units_attr_key in alias_map:
|
|
531
|
+
# Tell the printer that we want to print the reference using the short name
|
|
532
|
+
return syside.NamePreference.Shortest
|
|
533
|
+
|
|
534
|
+
# Otherwise print using regular settings
|
|
535
|
+
return syside.NamePreference.Regular
|
|
536
|
+
|
|
537
|
+
return syside.ReferencePrinter(get_name_pref)
|
|
538
|
+
|
|
539
|
+
###########################################
|
|
540
|
+
### Unit conversion / parsing functions ###
|
|
541
|
+
###########################################
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
def get_python_units(cls, units_attr: Union[syside.AttributeUsage, str], raise_if_unknown_unit=True,
|
|
545
|
+
cache_unknown=True) -> Optional[Unit]:
|
|
546
|
+
"""Convert a units attribute (SysML) to pint/Python units."""
|
|
547
|
+
|
|
548
|
+
# Check if dimensionless
|
|
549
|
+
units_attr_key = cls._sysml_attr_key(units_attr)
|
|
550
|
+
if units_attr_key == cls.dimensionless_units_sysml_key:
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
# Check the cache
|
|
554
|
+
pint_units = cls._get_cache_from_sysml(units_attr, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
555
|
+
if pint_units is not None:
|
|
556
|
+
return pint_units
|
|
557
|
+
|
|
558
|
+
# Try all aliases of the units attribute
|
|
559
|
+
for alias in cls._sysml_alias_map.get(units_attr_key, []):
|
|
560
|
+
pint_units = cls.parse_python_units(alias, raise_if_unknown_unit=False)
|
|
561
|
+
if pint_units is not None:
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
# Expand the search by replacing special characters by more common ones
|
|
565
|
+
if pint_units is None:
|
|
566
|
+
for alias in cls._sysml_alias_map.get(units_attr_key, []):
|
|
567
|
+
common_alias = (
|
|
568
|
+
alias
|
|
569
|
+
.replace('⋅', '*')
|
|
570
|
+
.replace('metre', 'meter')
|
|
571
|
+
.replace('litre', 'liter')
|
|
572
|
+
)
|
|
573
|
+
pint_units = cls.parse_python_units(common_alias, raise_if_unknown_unit=False)
|
|
574
|
+
if pint_units is not None:
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
# Check if the units were found
|
|
578
|
+
units_attr_str = f'<{units_attr.short_name or ""}> {units_attr.name}' \
|
|
579
|
+
if isinstance(units_attr, syside.AttributeUsage) else units_attr
|
|
580
|
+
if pint_units is not None:
|
|
581
|
+
if cls.do_log:
|
|
582
|
+
log.debug(f'Converted SysML units "{units_attr_str}" to pint units "<{pint_units:~P}> {pint_units:P}"')
|
|
583
|
+
|
|
584
|
+
cls._cache_units_map(pint_units, units_attr)
|
|
585
|
+
return pint_units
|
|
586
|
+
|
|
587
|
+
# No units were found
|
|
588
|
+
if cls.do_log:
|
|
589
|
+
log.debug(f'Could not convert SysML units "{units_attr_str}" to pint units')
|
|
590
|
+
if cache_unknown:
|
|
591
|
+
cls._cache_units_map(_UNKNOWN_UNIT, units_attr)
|
|
592
|
+
|
|
593
|
+
# Raise an error if needed
|
|
594
|
+
if raise_if_unknown_unit:
|
|
595
|
+
raise UndefinedUnitError(units_attr_str)
|
|
596
|
+
|
|
597
|
+
def get_sysml_units(self, units: Union[Unit, str] = None, raise_if_unknown_unit=True, cache_unknown=True) \
|
|
598
|
+
-> Optional[syside.AttributeUsage]:
|
|
599
|
+
"""Gets SysML units for the given Python/pint units. Returns None for dimensionless."""
|
|
600
|
+
|
|
601
|
+
units_str_try = []
|
|
602
|
+
|
|
603
|
+
# Convert to a Unit object if needed
|
|
604
|
+
if not isinstance(units, Unit):
|
|
605
|
+
|
|
606
|
+
if isinstance(units, str):
|
|
607
|
+
units_str_try.append(units)
|
|
608
|
+
|
|
609
|
+
units = self.parse_python_units(units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
610
|
+
|
|
611
|
+
# Check if dimensionless
|
|
612
|
+
if units is None or units == self.dimensionless_units_pint:
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
assert isinstance(units, Unit)
|
|
616
|
+
|
|
617
|
+
# Check the mapping cache
|
|
618
|
+
units_attr = self._get_cache_from_pint(units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
619
|
+
if units_attr is not None:
|
|
620
|
+
return units_attr
|
|
621
|
+
|
|
622
|
+
# Get string to search for by printing the units in various formats
|
|
623
|
+
def _get_str_formats(u: Unit):
|
|
624
|
+
formats = [
|
|
625
|
+
str(u),
|
|
626
|
+
str(u).replace('_', ' '),
|
|
627
|
+
f'{u:C}', # Compact
|
|
628
|
+
f'{u:P}', # Pretty-printed
|
|
629
|
+
f'{u:~C}', # Abbreviated, compact
|
|
630
|
+
f'{u:~P}', # Abbreviated, pretty-printed
|
|
631
|
+
f'{u:S}', # SysML-style: see function _print_sysml_like
|
|
632
|
+
]
|
|
633
|
+
if 'meter' in formats[0] or 'liter' in formats[0]:
|
|
634
|
+
formats += [fmt
|
|
635
|
+
.replace('meter', 'metre')
|
|
636
|
+
.replace('liter', 'litre')
|
|
637
|
+
for fmt in formats]
|
|
638
|
+
return formats
|
|
639
|
+
|
|
640
|
+
units_str_try += _get_str_formats(units)
|
|
641
|
+
|
|
642
|
+
# Search for the associated SysML units
|
|
643
|
+
unique_search_str = list(OrderedDict.fromkeys(units_str_try).keys())
|
|
644
|
+
for search_str in unique_search_str:
|
|
645
|
+
units_attr_key = self._sysml_units_map.get(search_str.strip())
|
|
646
|
+
|
|
647
|
+
if units_attr_key is not None:
|
|
648
|
+
# Get and return the found units
|
|
649
|
+
units_attr = self._sysml_obj_map[units_attr_key]
|
|
650
|
+
assert units_attr != self.dimensionless_units_sysml
|
|
651
|
+
|
|
652
|
+
self._cache_units_map(units, units_attr)
|
|
653
|
+
if self.__class__.do_log:
|
|
654
|
+
log.debug(f'Converted pint units "<{units:~P}> {units:P}" '
|
|
655
|
+
f'to SysML units "<{units_attr.short_name or ""}> {units_attr.name}"')
|
|
656
|
+
return units_attr
|
|
657
|
+
|
|
658
|
+
# Remember that the search was unsuccessful
|
|
659
|
+
if self.__class__.do_log:
|
|
660
|
+
log.debug(f'Could not convert pint units "<{units:~P}> {units:P}" to SysML units')
|
|
661
|
+
if cache_unknown:
|
|
662
|
+
self._cache_units_map(units, _UNKNOWN_UNIT)
|
|
663
|
+
|
|
664
|
+
# Raise an error if needed
|
|
665
|
+
if raise_if_unknown_unit:
|
|
666
|
+
raise UndefinedUnitError(str(units))
|
|
667
|
+
|
|
668
|
+
@classmethod
|
|
669
|
+
def parse_python_units(cls, units: Union[Unit, str] = None, raise_if_unknown_unit=True) -> Optional[Unit]:
|
|
670
|
+
"""Parses a str to Python/pint unit if needed. Returns None for dimensionless."""
|
|
671
|
+
|
|
672
|
+
# Check if we already have a Unit object
|
|
673
|
+
if isinstance(units, Unit):
|
|
674
|
+
|
|
675
|
+
# Check if it is dimensionless
|
|
676
|
+
if units == cls.dimensionless_units_pint:
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
return units
|
|
680
|
+
|
|
681
|
+
# Check if the units are empty
|
|
682
|
+
if not units:
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
# Try to parse the units using pint
|
|
686
|
+
try:
|
|
687
|
+
parsed_units = ureg(units).units
|
|
688
|
+
|
|
689
|
+
# Check if dimensionless
|
|
690
|
+
if parsed_units == cls.dimensionless_units_pint:
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
return parsed_units
|
|
694
|
+
|
|
695
|
+
except UndefinedUnitError:
|
|
696
|
+
if raise_if_unknown_unit:
|
|
697
|
+
raise
|
|
698
|
+
|
|
699
|
+
except Exception:
|
|
700
|
+
if raise_if_unknown_unit:
|
|
701
|
+
raise UndefinedUnitError(units)
|
|
702
|
+
|
|
703
|
+
if cls.do_log:
|
|
704
|
+
log.debug(f'Could not parse "{units}" to pint units')
|
|
705
|
+
|
|
706
|
+
def quantity(self, value: float, units: Unit = None) -> Quantity:
|
|
707
|
+
"""Quantity object factory"""
|
|
708
|
+
return ureg.Quantity(value, units or self.dimensionless_units_pint)
|
|
709
|
+
|
|
710
|
+
def _get_cache_from_pint(self, pint_units: Unit, raise_if_unknown_unit=True) -> Optional[syside.AttributeUsage]:
|
|
711
|
+
units_attr_key = self._load_cache_from_pint(pint_units, raise_if_unknown_unit=raise_if_unknown_unit)
|
|
712
|
+
if units_attr_key is not None:
|
|
713
|
+
return self._sysml_obj_map[units_attr_key]
|
|
714
|
+
|
|
715
|
+
@classmethod
|
|
716
|
+
def _load_cache_from_pint(cls, pint_units: Unit, raise_if_unknown_unit=True) -> Optional[str]:
|
|
717
|
+
|
|
718
|
+
# Check the cache
|
|
719
|
+
if pint_units not in cls._pint_sysml_units_map:
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
units_attr_key = cls._pint_sysml_units_map[pint_units]
|
|
723
|
+
|
|
724
|
+
# If the SysML units were unknown, raise if requested or return None
|
|
725
|
+
if units_attr_key == _UNKNOWN_UNIT:
|
|
726
|
+
if raise_if_unknown_unit:
|
|
727
|
+
raise UndefinedUnitError(str(pint_units))
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
return units_attr_key
|
|
731
|
+
|
|
732
|
+
@classmethod
|
|
733
|
+
def _get_cache_from_sysml(cls, units_attr: syside.AttributeUsage, raise_if_unknown_unit=True) -> Optional[Unit]:
|
|
734
|
+
|
|
735
|
+
# Check the cache
|
|
736
|
+
units_attr_key = cls._sysml_attr_key(units_attr)
|
|
737
|
+
if units_attr_key not in cls._sysml_pint_units_map:
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
pint_units = cls._sysml_pint_units_map[units_attr_key]
|
|
741
|
+
|
|
742
|
+
# If the pint units were unknown, raise if requested or return None
|
|
743
|
+
if pint_units == _UNKNOWN_UNIT:
|
|
744
|
+
if raise_if_unknown_unit:
|
|
745
|
+
raise UndefinedUnitError(units_attr.name)
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
return pint_units
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _sysml_attr_key(sysml_attr: syside.AttributeUsage):
|
|
752
|
+
"""Normalize a SysMl attr to its qualified name,
|
|
753
|
+
so that we can reuse the SysML-side cache across Syside model instances"""
|
|
754
|
+
if sysml_attr == _UNKNOWN_UNIT or isinstance(sysml_attr, str):
|
|
755
|
+
return sysml_attr
|
|
756
|
+
return str(sysml_attr.qualified_name)
|
|
757
|
+
|
|
758
|
+
@classmethod
|
|
759
|
+
def _cache_units_map(cls, pint_units: Union[Unit, _UNKNOWN_UNIT],
|
|
760
|
+
sysml_attr: Union[syside.AttributeUsage, str, _UNKNOWN_UNIT]):
|
|
761
|
+
"""Add a mapping result to the cache"""
|
|
762
|
+
|
|
763
|
+
sysml_attr_key = cls._sysml_attr_key(sysml_attr)
|
|
764
|
+
|
|
765
|
+
if pint_units in cls._pint_sysml_units_map or sysml_attr_key in cls._sysml_pint_units_map:
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
if pint_units != _UNKNOWN_UNIT:
|
|
769
|
+
cls._pint_sysml_units_map[pint_units] = sysml_attr_key
|
|
770
|
+
if sysml_attr_key != _UNKNOWN_UNIT:
|
|
771
|
+
cls._sysml_pint_units_map[sysml_attr_key] = pint_units
|
|
772
|
+
|
|
773
|
+
def _get_sysml_units_map(self):
|
|
774
|
+
"""Get the initial SysML units map from the SysML v2 standard library."""
|
|
775
|
+
|
|
776
|
+
sysml_alias_map = {}
|
|
777
|
+
sysml_units_map = {}
|
|
778
|
+
sysml_object_map = {}
|
|
779
|
+
|
|
780
|
+
# Loop over all unit elements
|
|
781
|
+
for library in self.units_libraries:
|
|
782
|
+
for units_attr in library.owned_elements:
|
|
783
|
+
if (isinstance(units_attr, syside.AttributeUsage) and
|
|
784
|
+
(units_attr.specializes(self.unit_base_def) or units_attr.specializes(self.scale_base_def))):
|
|
785
|
+
|
|
786
|
+
units_attr_key = self._sysml_attr_key(units_attr)
|
|
787
|
+
if units_attr_key in sysml_alias_map:
|
|
788
|
+
continue
|
|
789
|
+
sysml_alias_map[units_attr_key] = []
|
|
790
|
+
sysml_object_map[units_attr_key] = units_attr
|
|
791
|
+
|
|
792
|
+
if units_attr.short_name:
|
|
793
|
+
sysml_units_map[units_attr.short_name.strip()] = units_attr_key
|
|
794
|
+
sysml_alias_map[units_attr_key].append(units_attr.short_name.strip())
|
|
795
|
+
if units_attr.name:
|
|
796
|
+
sysml_units_map[units_attr.name.strip()] = units_attr_key
|
|
797
|
+
sysml_alias_map[units_attr_key].append(units_attr.name.strip())
|
|
798
|
+
|
|
799
|
+
# Loop over all alias elements
|
|
800
|
+
for library in self.units_libraries:
|
|
801
|
+
for alias, units_attr in library.children:
|
|
802
|
+
# An alias is simply a membership relationship pointing to the element it is aliasing
|
|
803
|
+
# The relationship has the name declared
|
|
804
|
+
if alias.__class__ == syside.Membership:
|
|
805
|
+
|
|
806
|
+
units_attr_key = self._sysml_attr_key(units_attr)
|
|
807
|
+
if units_attr_key not in sysml_alias_map:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
if alias.name:
|
|
811
|
+
sysml_alias_map[units_attr_key].append(alias.name.strip())
|
|
812
|
+
sysml_units_map[alias.name.strip()] = units_attr_key
|
|
813
|
+
if alias.short_name:
|
|
814
|
+
sysml_alias_map[units_attr_key].append(alias.short_name.strip())
|
|
815
|
+
sysml_units_map[alias.short_name.strip()] = units_attr_key
|
|
816
|
+
|
|
817
|
+
return sysml_alias_map, sysml_units_map, sysml_object_map
|
|
818
|
+
|
|
819
|
+
@classmethod
|
|
820
|
+
def _init_mappings(cls, sysml_units_map, sysml_alias_map):
|
|
821
|
+
"""Initialize mappings for actual use by adding explicit mappings and removing faulty mappings."""
|
|
822
|
+
|
|
823
|
+
# Check if the caches are already initialized
|
|
824
|
+
if len(cls._pint_sysml_units_map) > 0:
|
|
825
|
+
return
|
|
826
|
+
cls._sysml_units_map = sysml_units_map
|
|
827
|
+
cls._sysml_alias_map = sysml_alias_map
|
|
828
|
+
|
|
829
|
+
# Explicitly cache some mappings
|
|
830
|
+
for pint_str, sysml_str in cls._explicit_units_map:
|
|
831
|
+
cls._cache_units_map(cls.parse_python_units(pint_str), sysml_units_map[sysml_str])
|
|
832
|
+
|
|
833
|
+
# Remove some units of measure that are represented by duplicate base units
|
|
834
|
+
for remove_attr_from_map in cls._units_remove_list:
|
|
835
|
+
units_attr_key = sysml_units_map[remove_attr_from_map]
|
|
836
|
+
|
|
837
|
+
for alias in sysml_alias_map[units_attr_key]:
|
|
838
|
+
del sysml_units_map[alias]
|
|
839
|
+
del sysml_alias_map[units_attr_key]
|
|
840
|
+
|
|
841
|
+
# Map all SysML units to pint units (more robust, because compound pint units are order-independent)
|
|
842
|
+
cls.do_log = False
|
|
843
|
+
for units_attr_key in sysml_alias_map:
|
|
844
|
+
cls.get_python_units(units_attr_key, raise_if_unknown_unit=False)
|
|
845
|
+
|
|
846
|
+
cls.do_log = True
|
|
847
|
+
|
|
848
|
+
@staticmethod
|
|
849
|
+
@register_unit_format('S')
|
|
850
|
+
def _print_sysml_like(units: Unit, registry, **_):
|
|
851
|
+
"""Print multiplicative: m⋅s⁻² instead of m/s²"""
|
|
852
|
+
from pint.delegates.formatter._format_helpers import pretty_fmt_exponent
|
|
853
|
+
from pint.delegates.formatter._compound_unit_helpers import to_symbol_exponent_name
|
|
854
|
+
|
|
855
|
+
return '⋅'.join([
|
|
856
|
+
to_symbol_exponent_name((u, p), registry=registry)[0] +
|
|
857
|
+
(pretty_fmt_exponent(p) if p != 1 else '')
|
|
858
|
+
for u, p in units.items()])
|
|
859
|
+
|
|
860
|
+
##################################
|
|
861
|
+
### Quantity parsing functions ###
|
|
862
|
+
##################################
|
|
863
|
+
|
|
864
|
+
def is_typed_by_quantity_value(self, attr: Union[syside.AttributeUsage, syside.AttributeDefinition]):
|
|
865
|
+
"""Check if an attribute is typed by a quantity base value."""
|
|
866
|
+
return attr.specializes(self.quantity_value_base_def)
|
|
867
|
+
|
|
868
|
+
def get_quantity_value_units(self, quantity_value_attr: Union[syside.AttributeUsage, syside.AttributeDefinition],
|
|
869
|
+
raise_if_unknown_unit=True) -> Optional[Unit]:
|
|
870
|
+
"""
|
|
871
|
+
Get the preferred (pint) units of a QuantityValue or QuantityUnit.
|
|
872
|
+
|
|
873
|
+
For example:
|
|
874
|
+
- ISQBase::MassValue --> kg
|
|
875
|
+
- ISQSpaceTime::SpeedValue --> m/s
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
# Get the attribute def
|
|
879
|
+
if isinstance(quantity_value_attr, syside.AttributeUsage):
|
|
880
|
+
for relationship, attr_def in quantity_value_attr.heritage:
|
|
881
|
+
if isinstance(relationship, syside.FeatureTyping) and isinstance(attr_def, syside.AttributeDefinition):
|
|
882
|
+
quantity_value_attr = attr_def
|
|
883
|
+
break
|
|
884
|
+
|
|
885
|
+
quantity_value_key = self._sysml_attr_key(quantity_value_attr)
|
|
886
|
+
quantity_value_str = quantity_value_attr.name
|
|
887
|
+
|
|
888
|
+
# Check cache
|
|
889
|
+
if quantity_value_key in self._sysml_quantities_units_map:
|
|
890
|
+
pint_units = self._sysml_quantities_units_map[quantity_value_key]
|
|
891
|
+
|
|
892
|
+
if pint_units == _UNKNOWN_UNIT:
|
|
893
|
+
if raise_if_unknown_unit:
|
|
894
|
+
raise UndefinedUnitError(quantity_value_str)
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
return pint_units
|
|
898
|
+
|
|
899
|
+
pint_units = None
|
|
900
|
+
|
|
901
|
+
# Get the quantity dimension: try directly from a QuantityUnit
|
|
902
|
+
quantity_units_def = None
|
|
903
|
+
quantity_dimension = self._get_feature_by_name(quantity_value_attr, 'quantityDimension')
|
|
904
|
+
if quantity_dimension is not None:
|
|
905
|
+
quantity_units_def = quantity_value_attr
|
|
906
|
+
else:
|
|
907
|
+
# Get it from the references quantity unit (of a QuantityValue)
|
|
908
|
+
m_ref = self._get_feature_by_name(quantity_value_attr, 'mRef')
|
|
909
|
+
if m_ref:
|
|
910
|
+
for relationship, quantity_units in m_ref.heritage:
|
|
911
|
+
if isinstance(relationship, syside.FeatureTyping):
|
|
912
|
+
|
|
913
|
+
quantity_dimension = self._get_feature_by_name(quantity_units, 'quantityDimension')
|
|
914
|
+
if quantity_dimension is not None:
|
|
915
|
+
quantity_units_def = quantity_units
|
|
916
|
+
|
|
917
|
+
# Check if the quantity units derive from the dimensionless units
|
|
918
|
+
if quantity_units_def is not None and quantity_units_def.specializes(self.dimensionless_units_def_sysml):
|
|
919
|
+
pint_units = self.dimensionless_units_pint
|
|
920
|
+
|
|
921
|
+
if pint_units is None and quantity_dimension is not None:
|
|
922
|
+
# Get the quantity power factors: the list of quantities and their exponents
|
|
923
|
+
quantity_power_factors = self._get_feature_by_name(quantity_dimension, 'quantityPowerFactors')
|
|
924
|
+
|
|
925
|
+
if quantity_power_factors.feature_value and quantity_power_factors.feature_value.value:
|
|
926
|
+
power_factors = None
|
|
927
|
+
try:
|
|
928
|
+
power_factors = self._simple_parse_value(quantity_power_factors.feature_value.value)
|
|
929
|
+
|
|
930
|
+
if not isinstance(power_factors, list):
|
|
931
|
+
power_factors = [power_factors]
|
|
932
|
+
|
|
933
|
+
except ValueError:
|
|
934
|
+
pass
|
|
935
|
+
|
|
936
|
+
# Loop over power factors (quantities + exponents)
|
|
937
|
+
if power_factors is not None:
|
|
938
|
+
for power_factor in power_factors:
|
|
939
|
+
if not isinstance(power_factor, syside.AttributeUsage):
|
|
940
|
+
continue
|
|
941
|
+
|
|
942
|
+
# Get the quantity and its preferred units
|
|
943
|
+
quantity_feature = self._get_feature_by_name(power_factor, 'quantity')
|
|
944
|
+
quantity = self._simple_parse_value(quantity_feature.feature_value.value)
|
|
945
|
+
|
|
946
|
+
quantity_key = self._sysml_attr_key(quantity)
|
|
947
|
+
if quantity_key not in self._sysml_quantities_units_map:
|
|
948
|
+
raise ValueError(
|
|
949
|
+
f'Base quantity not found (while parsing {quantity_value_str}): {quantity_key}')
|
|
950
|
+
units = self._sysml_quantities_units_map[quantity_key]
|
|
951
|
+
if units is None: # Dimensionless
|
|
952
|
+
continue
|
|
953
|
+
|
|
954
|
+
exponent_feature = self._get_feature_by_name(power_factor, 'exponent')
|
|
955
|
+
exponent = self._simple_parse_value(exponent_feature.feature_value.value)
|
|
956
|
+
|
|
957
|
+
# Parse Pint units and extend the overall units with this power factor
|
|
958
|
+
pint_units_part = units ** exponent if exponent != 1 else units
|
|
959
|
+
if pint_units is None:
|
|
960
|
+
pint_units = pint_units_part
|
|
961
|
+
else:
|
|
962
|
+
pint_units *= pint_units_part
|
|
963
|
+
|
|
964
|
+
# Check if the units were found
|
|
965
|
+
if pint_units is not None:
|
|
966
|
+
if self.do_log:
|
|
967
|
+
log.debug(f'Found default pint units for SysML quantity "{quantity_value_str}": '
|
|
968
|
+
f'"<{pint_units:~P}> {pint_units:P}"')
|
|
969
|
+
|
|
970
|
+
if pint_units == self.dimensionless_units_pint:
|
|
971
|
+
pint_units = None
|
|
972
|
+
|
|
973
|
+
self._sysml_quantities_units_map[quantity_value_key] = pint_units
|
|
974
|
+
return pint_units
|
|
975
|
+
|
|
976
|
+
# No units were found
|
|
977
|
+
if self.do_log:
|
|
978
|
+
log.debug(f'Could not convert SysML units "{quantity_value_str}" to pint units')
|
|
979
|
+
self._sysml_quantities_units_map[quantity_value_key] = _UNKNOWN_UNIT
|
|
980
|
+
|
|
981
|
+
# Raise an error if needed
|
|
982
|
+
if raise_if_unknown_unit:
|
|
983
|
+
raise UndefinedUnitError(quantity_value_str)
|
|
984
|
+
|
|
985
|
+
@classmethod
|
|
986
|
+
def _map_base_quantities(cls, system_of_quantities: syside.AttributeUsage, system_of_units: syside.AttributeUsage,
|
|
987
|
+
dimensionless_quantities: list):
|
|
988
|
+
"""
|
|
989
|
+
Maps quantities (e.g. length, mass) from a SystemOfQuantities to default/preferred units (e.g. m, kg)
|
|
990
|
+
in a SystemOfUnits.
|
|
991
|
+
|
|
992
|
+
This provides the basis for `get_quantity_value_units`, which either looks up the base quantity types or
|
|
993
|
+
composite quantity types, which all refer to the base types in the end.
|
|
994
|
+
"""
|
|
995
|
+
|
|
996
|
+
# Get the quantities and units
|
|
997
|
+
quantities = cls._get_feature_by_name(system_of_quantities, 'baseQuantities')
|
|
998
|
+
units = cls._get_feature_by_name(system_of_units, 'baseUnits')
|
|
999
|
+
if quantities is None or units is None:
|
|
1000
|
+
raise ValueError(f'Malformed base quantities/units: '
|
|
1001
|
+
f'{system_of_quantities.qualified_name}, {system_of_units.qualified_name}')
|
|
1002
|
+
|
|
1003
|
+
assert quantities.feature_value and quantities.feature_value.value
|
|
1004
|
+
quantities = cls._simple_parse_value(quantities.feature_value.value)
|
|
1005
|
+
assert units.feature_value and units.feature_value.value
|
|
1006
|
+
units = cls._simple_parse_value(units.feature_value.value)
|
|
1007
|
+
|
|
1008
|
+
assert len(quantities) == len(units)
|
|
1009
|
+
|
|
1010
|
+
# Map quantities to units
|
|
1011
|
+
quantities_units_map = {}
|
|
1012
|
+
for i, quantity in enumerate(quantities):
|
|
1013
|
+
unit = units[i]
|
|
1014
|
+
pint_units = cls.get_python_units(unit)
|
|
1015
|
+
|
|
1016
|
+
# Map quantity name (L, M, etc.) to default units
|
|
1017
|
+
quantities_units_map[cls._sysml_attr_key(quantity)] = pint_units
|
|
1018
|
+
|
|
1019
|
+
# Map quantity type (LengthValue, MassValue, etc.) to default units
|
|
1020
|
+
quantity_value_type = None
|
|
1021
|
+
for relationship, heritage in quantity.heritage:
|
|
1022
|
+
if isinstance(relationship, syside.FeatureTyping) and heritage.name.endswith('Value'):
|
|
1023
|
+
quantity_value_type = heritage
|
|
1024
|
+
if quantity_value_type is None:
|
|
1025
|
+
raise ValueError(f'Malformed base quantity: {quantity.qualified_name}')
|
|
1026
|
+
quantities_units_map[cls._sysml_attr_key(quantity_value_type)] = pint_units
|
|
1027
|
+
|
|
1028
|
+
# Map dimensionless quantities
|
|
1029
|
+
for dimensionless_quantity in dimensionless_quantities:
|
|
1030
|
+
quantities_units_map[cls._sysml_attr_key(dimensionless_quantity)] = None
|
|
1031
|
+
|
|
1032
|
+
return quantities_units_map
|
|
1033
|
+
|
|
1034
|
+
########################
|
|
1035
|
+
### Helper functions ###
|
|
1036
|
+
########################
|
|
1037
|
+
|
|
1038
|
+
@staticmethod
|
|
1039
|
+
def _get_feature_by_name(element: syside.Type, name: str) -> Optional[syside.Feature]:
|
|
1040
|
+
for feature in element.features:
|
|
1041
|
+
if feature.name == name:
|
|
1042
|
+
return feature
|
|
1043
|
+
|
|
1044
|
+
@classmethod
|
|
1045
|
+
def _get_element_by_qualified_name(
|
|
1046
|
+
cls,
|
|
1047
|
+
model: syside.Model,
|
|
1048
|
+
doc_namespace_map: dict,
|
|
1049
|
+
qualified_name: str,
|
|
1050
|
+
kind: Type[syside.Element] = None,
|
|
1051
|
+
env: bool = False,
|
|
1052
|
+
) -> Optional[syside.Element]:
|
|
1053
|
+
"""
|
|
1054
|
+
Resolve a qualified name like "Pkg::SubPkg::ElementName".
|
|
1055
|
+
Traverses nested namespaces/packages until the final element is found.
|
|
1056
|
+
|
|
1057
|
+
If `kind` is given, ensures the result is of that type.
|
|
1058
|
+
If `env` is True, also searches the environment (stdlib).
|
|
1059
|
+
"""
|
|
1060
|
+
# Split the qualified name by '::'
|
|
1061
|
+
name_parts = [p.strip() for p in qualified_name.split("::") if p.strip()]
|
|
1062
|
+
if not name_parts:
|
|
1063
|
+
return None
|
|
1064
|
+
|
|
1065
|
+
# Start from the root context (MODEL or ENVIRONMENT depending on env flag)
|
|
1066
|
+
current = cls._search_namespace(model, doc_namespace_map, name_parts[0], env=env)
|
|
1067
|
+
|
|
1068
|
+
if current is None:
|
|
1069
|
+
return None
|
|
1070
|
+
|
|
1071
|
+
# Walk down through nested parts
|
|
1072
|
+
for name_part in name_parts[1:]:
|
|
1073
|
+
found = None
|
|
1074
|
+
for owned in getattr(current, "owned_elements", []):
|
|
1075
|
+
if owned.name == name_part or (owned.short_name and owned.short_name == name_part):
|
|
1076
|
+
found = owned
|
|
1077
|
+
break
|
|
1078
|
+
if found is None:
|
|
1079
|
+
return None
|
|
1080
|
+
current = found
|
|
1081
|
+
|
|
1082
|
+
# Check type if specified
|
|
1083
|
+
if kind and not isinstance(current, kind):
|
|
1084
|
+
return None
|
|
1085
|
+
|
|
1086
|
+
return current
|
|
1087
|
+
|
|
1088
|
+
@staticmethod
|
|
1089
|
+
def _search_namespace(model: syside.Model, doc_namespace_map: dict, name: str, env=False) \
|
|
1090
|
+
-> Optional[syside.Namespace]:
|
|
1091
|
+
"""More efficient function to search for a root namespace (e.g. package) in all documents."""
|
|
1092
|
+
|
|
1093
|
+
if (name, env) in doc_namespace_map:
|
|
1094
|
+
return doc_namespace_map[name, env]
|
|
1095
|
+
|
|
1096
|
+
for doc_mutex in (model.environment.documents if env else model.documents):
|
|
1097
|
+
with doc_mutex.lock() as doc:
|
|
1098
|
+
root_node = doc.root_node
|
|
1099
|
+
for namespace_node in root_node.children.elements:
|
|
1100
|
+
if namespace_node.name == name:
|
|
1101
|
+
|
|
1102
|
+
doc_namespace_map[name, env] = namespace_node
|
|
1103
|
+
return namespace_node
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sysmlv2-units
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SysML v2 Python Units
|
|
5
|
+
Author-email: Jasper Bussemaker <jasper.bussemaker@dlr.de>
|
|
6
|
+
Keywords: sysmlv2,units,quantities,syside,pint
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: syside
|
|
14
|
+
Requires-Dist: pint
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Provides-Extra: nb
|
|
18
|
+
Requires-Dist: jupyter; extra == "nb"
|
|
19
|
+
Requires-Dist: ipython; extra == "nb"
|
|
20
|
+
Requires-Dist: ipykernel; extra == "nb"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# SysML v2 Python Units
|
|
24
|
+
|
|
25
|
+
[](https://github.com/jbussemaker/SysML-v2-Python-Units/actions/workflows/tests.yml?query=workflow%3ATests)
|
|
26
|
+
[](https://pypi.org/project/sysmlv2-units)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
Convert units and quantities between standard [SysML v2](https://www.omg.org/sysml/sysmlv2/) and Python with
|
|
30
|
+
[Syside Automator](https://docs.sensmetry.com/automator/) and [Pint](https://pint.readthedocs.io/).
|
|
31
|
+
|
|
32
|
+
- Get/set quantity* as `attribute` value, specified using Pint `Quantity` objects
|
|
33
|
+
- Get/set unit as `attribute` value, specified using Pint `Unit` objects
|
|
34
|
+
- Supports numerical values, as well as `inf` (`*` in SysML v2) and `NaN` (set as `null` in SysML v2)
|
|
35
|
+
- Conversion functions between SysML v2 units and Pint `Unit`, and string parsing functions
|
|
36
|
+
- Derive preferred units for a quantity type, for example `kg` for an `ISQ::mass` quantity
|
|
37
|
+
- Syside Automator [`ReferencePrinter`](https://docs.sensmetry.com/python/latest/syside/ReferencePrinter.html) for
|
|
38
|
+
printing unit references using their short name
|
|
39
|
+
- Extensive caching to make units lookup fast
|
|
40
|
+
|
|
41
|
+
*: a quantity is a combination of a numerical value (the "magnitude") and units, for example: `10 kg`, `-1.0 m/s**2`.
|
|
42
|
+
|
|
43
|
+
Note: this package uses [Syside Automator](https://docs.sensmetry.com/automator/) for parsing SysML v2 models.
|
|
44
|
+
Syside Automator is commercial software, so you have to obtain a license first.
|
|
45
|
+
For academic uses you can request an [academic license](https://sensmetry.com/syside-pricing/).
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
1. Install the package from PyPI:
|
|
50
|
+
```
|
|
51
|
+
pip install sysmlv2-units
|
|
52
|
+
```
|
|
53
|
+
Note: currently Syside Automator only supports Python 3.12
|
|
54
|
+
2. Make sure you [activate Syside Automator](https://docs.sensmetry.com/automator/install.html#activate-license)
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
Refer to [the documentation](https://github.com/jbussemaker/SysML-v2-Python-Units/blob/main/documentation.ipynb).
|
|
59
|
+
|
|
60
|
+
## Citing
|
|
61
|
+
|
|
62
|
+
If you use this library in your work, please cite the paper that first introduces the use of these capabilities:
|
|
63
|
+
|
|
64
|
+
Bussemaker, J.H. et al., 2026, April.
|
|
65
|
+
System Architecture Optimization Using SysML v2: Language Extension and Implementation.
|
|
66
|
+
IEEE SysCon 2026, Halifax, Canada.
|
|
67
|
+
doi: [10.1109/SysCon66367.2026.11503593](https://dx.doi.org/10.1109/SysCon66367.2026.11503593)
|
|
68
|
+
|
|
69
|
+
## Contributing
|
|
70
|
+
|
|
71
|
+
The project is coordinated by: Jasper Bussemaker (*jasper.bussemaker at dlr.de*)
|
|
72
|
+
|
|
73
|
+
If you find a bug or have a feature request, please file an issue using the Github issue tracker.
|
|
74
|
+
If you require support for using the library or want to collaborate, feel free to contact me.
|
|
75
|
+
|
|
76
|
+
Contributions are appreciated too:
|
|
77
|
+
- Fork the repository
|
|
78
|
+
- Add your contributions to the fork
|
|
79
|
+
- Update/add documentation
|
|
80
|
+
- Add tests and make sure they pass (tests are run using `pytest`)
|
|
81
|
+
- Sign a Contributor License Agreement (CLA): *please contact me for the template*
|
|
82
|
+
- Issue a pull request
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sysmlv2_units/__init__.py,sha256=deNXn0GIKUvdxcF_0LBUOV75tJ20zdSro5JkQuJa-AI,64
|
|
2
|
+
sysmlv2_units/units_helper.py,sha256=5aB6uTDA2kCbaBAvaVfD-QyOGXAdPka6chGLrbz-4bU,47622
|
|
3
|
+
sysmlv2_units-1.0.0.dist-info/licenses/LICENSE,sha256=DlVm3qcIWiE59eRFLV5nUFFakplGSqET0P1NWuuPoH0,1074
|
|
4
|
+
sysmlv2_units-1.0.0.dist-info/METADATA,sha256=Srwo3ScndvTZ5GhB98Uo4RvmPliPLymZ6r3KOpXJekQ,3713
|
|
5
|
+
sysmlv2_units-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
sysmlv2_units-1.0.0.dist-info/top_level.txt,sha256=pWYvTsxCmyjKmfC09TUFjTT9Z22VCME6Uww_ZuPyCuY,14
|
|
7
|
+
sysmlv2_units-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jasper Bussemaker
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sysmlv2_units
|