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.
@@ -0,0 +1,3 @@
1
+ __version__ = '1.0.0'
2
+
3
+ from sysmlv2_units.units_helper import *
@@ -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
+ [![Tests](https://github.com/jbussemaker/SysML-v2-Python-Units/workflows/Tests/badge.svg)](https://github.com/jbussemaker/SysML-v2-Python-Units/actions/workflows/tests.yml?query=workflow%3ATests)
26
+ [![PyPI](https://img.shields.io/pypi/v/sysmlv2-units.svg)](https://pypi.org/project/sysmlv2-units)
27
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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