sysml2kit 0.0.1__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,239 @@
1
+ """Fluent authoring helpers: the API humans and agents actually type.
2
+
3
+ Each helper constructs an element, registers it in the model under the given
4
+ owner, and returns it. The raw element classes stay the interchange-faithful
5
+ layer; nothing here adds state the classes lack.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sysml2kit.model.analysis import AnalysisCaseDefinition, AnalysisCaseUsage
11
+ from sysml2kit.model.base import Element, Ref
12
+ from sysml2kit.model.container import Model
13
+ from sysml2kit.model.metadata import MetadataUsage
14
+ from sysml2kit.model.relations import (
15
+ AllocateRelationship,
16
+ DeriveRelationship,
17
+ SatisfyRelationship,
18
+ VerifyRelationship,
19
+ )
20
+ from sysml2kit.model.requirements import RequirementDefinition, RequirementUsage
21
+ from sysml2kit.model.structure import (
22
+ AttributeDefinition,
23
+ AttributeUsage,
24
+ ConnectionUsage,
25
+ Package,
26
+ PartDefinition,
27
+ PartUsage,
28
+ PortDefinition,
29
+ PortUsage,
30
+ )
31
+ from sysml2kit.model.values import AttributeValue
32
+
33
+
34
+ def pkg(
35
+ model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
36
+ ) -> Package:
37
+ """Create a package."""
38
+ return model.add(Package(declared_name=name, doc=doc), owner=owner) # type: ignore[return-value]
39
+
40
+
41
+ def part_def(
42
+ model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
43
+ ) -> PartDefinition:
44
+ """Create a part definition."""
45
+ return model.add(PartDefinition(declared_name=name, doc=doc), owner=owner) # type: ignore[return-value]
46
+
47
+
48
+ def part(
49
+ model: Model,
50
+ name: str,
51
+ *,
52
+ owner: Element | None = None,
53
+ definition: Element | None = None,
54
+ multiplicity: str | None = None,
55
+ doc: str | None = None,
56
+ ) -> PartUsage:
57
+ """Create a part usage, optionally typed by a part definition."""
58
+ usage = PartUsage(
59
+ declared_name=name,
60
+ definition=Ref.to(definition) if definition else None,
61
+ multiplicity=multiplicity,
62
+ doc=doc,
63
+ )
64
+ return model.add(usage, owner=owner) # type: ignore[return-value]
65
+
66
+
67
+ def port_def(
68
+ model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
69
+ ) -> PortDefinition:
70
+ """Create a port definition."""
71
+ return model.add(PortDefinition(declared_name=name, doc=doc), owner=owner) # type: ignore[return-value]
72
+
73
+
74
+ def port(
75
+ model: Model,
76
+ name: str,
77
+ *,
78
+ owner: Element | None = None,
79
+ definition: Element | None = None,
80
+ ) -> PortUsage:
81
+ """Create a port usage on a part."""
82
+ usage = PortUsage(
83
+ declared_name=name,
84
+ definition=Ref.to(definition) if definition else None,
85
+ )
86
+ return model.add(usage, owner=owner) # type: ignore[return-value]
87
+
88
+
89
+ def connect(
90
+ model: Model,
91
+ source: Element,
92
+ target: Element,
93
+ *,
94
+ owner: Element | None = None,
95
+ name: str | None = None,
96
+ ) -> ConnectionUsage:
97
+ """Create a connection between two ports (or parts)."""
98
+ usage = ConnectionUsage(declared_name=name, source=Ref.to(source), target=Ref.to(target))
99
+ return model.add(usage, owner=owner) # type: ignore[return-value]
100
+
101
+
102
+ def attr_def(
103
+ model: Model,
104
+ name: str,
105
+ *,
106
+ owner: Element | None = None,
107
+ unit: str | None = None,
108
+ doc: str | None = None,
109
+ ) -> AttributeDefinition:
110
+ """Create an attribute definition, optionally with a default unit."""
111
+ return model.add( # type: ignore[return-value]
112
+ AttributeDefinition(declared_name=name, unit=unit, doc=doc), owner=owner
113
+ )
114
+
115
+
116
+ def attr(
117
+ model: Model,
118
+ name: str,
119
+ value: float | str | bool | None = None,
120
+ *,
121
+ owner: Element | None = None,
122
+ unit: str | None = None,
123
+ definition: Element | None = None,
124
+ source: str | None = None,
125
+ ) -> AttributeUsage:
126
+ """Create an attribute usage holding a value with optional unit and provenance."""
127
+ usage = AttributeUsage(
128
+ declared_name=name,
129
+ definition=Ref.to(definition) if definition else None,
130
+ value=AttributeValue(value=value, unit=unit, source=source) if value is not None else None,
131
+ )
132
+ return model.add(usage, owner=owner) # type: ignore[return-value]
133
+
134
+
135
+ def req_def(
136
+ model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
137
+ ) -> RequirementDefinition:
138
+ """Create a requirement definition."""
139
+ return model.add(RequirementDefinition(declared_name=name, doc=doc), owner=owner) # type: ignore[return-value]
140
+
141
+
142
+ def req(
143
+ model: Model,
144
+ short_name: str,
145
+ name: str,
146
+ *,
147
+ owner: Element | None = None,
148
+ text: str | None = None,
149
+ subject: Element | None = None,
150
+ definition: Element | None = None,
151
+ ) -> RequirementUsage:
152
+ """Create a requirement usage; ``short_name`` is the requirement id (e.g. REQ-001)."""
153
+ usage = RequirementUsage(
154
+ declared_short_name=short_name,
155
+ declared_name=name,
156
+ text=text,
157
+ subject=Ref.to(subject) if subject else None,
158
+ definition=Ref.to(definition) if definition else None,
159
+ )
160
+ return model.add(usage, owner=owner) # type: ignore[return-value]
161
+
162
+
163
+ def analysis_def(
164
+ model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
165
+ ) -> AnalysisCaseDefinition:
166
+ """Create an analysis case definition."""
167
+ return model.add(AnalysisCaseDefinition(declared_name=name, doc=doc), owner=owner) # type: ignore[return-value]
168
+
169
+
170
+ def analysis(
171
+ model: Model,
172
+ name: str,
173
+ *,
174
+ owner: Element | None = None,
175
+ subject: Element | None = None,
176
+ objective: str | None = None,
177
+ definition: Element | None = None,
178
+ ) -> AnalysisCaseUsage:
179
+ """Create an analysis case usage."""
180
+ usage = AnalysisCaseUsage(
181
+ declared_name=name,
182
+ subject=Ref.to(subject) if subject else None,
183
+ objective=objective,
184
+ definition=Ref.to(definition) if definition else None,
185
+ )
186
+ return model.add(usage, owner=owner) # type: ignore[return-value]
187
+
188
+
189
+ def metadata(
190
+ model: Model,
191
+ annotated: Element,
192
+ values: dict[str, str | float | int | bool],
193
+ *,
194
+ owner: Element | None = None,
195
+ name: str | None = None,
196
+ ) -> MetadataUsage:
197
+ """Attach a key-value metadata annotation to an element."""
198
+ usage = MetadataUsage(declared_name=name, annotated=Ref.to(annotated), values=dict(values))
199
+ return model.add(usage, owner=owner if owner is not None else annotated) # type: ignore[return-value]
200
+
201
+
202
+ def _relate(
203
+ model: Model,
204
+ cls: type[SatisfyRelationship | VerifyRelationship | DeriveRelationship | AllocateRelationship],
205
+ source: Element,
206
+ target: Element,
207
+ owner: Element | None,
208
+ ) -> Element:
209
+ rel = cls(source=Ref.to(source), target=Ref.to(target))
210
+ fallback = model.owner_of(source)
211
+ return model.add(rel, owner=owner if owner is not None else fallback)
212
+
213
+
214
+ def satisfy(
215
+ model: Model, *, source: Element, target: Element, owner: Element | None = None
216
+ ) -> SatisfyRelationship:
217
+ """Record that ``source`` (a design element) satisfies ``target`` (a requirement)."""
218
+ return _relate(model, SatisfyRelationship, source, target, owner) # type: ignore[return-value]
219
+
220
+
221
+ def verify(
222
+ model: Model, *, source: Element, target: Element, owner: Element | None = None
223
+ ) -> VerifyRelationship:
224
+ """Record that ``source`` (an analysis/test) verifies ``target`` (a requirement)."""
225
+ return _relate(model, VerifyRelationship, source, target, owner) # type: ignore[return-value]
226
+
227
+
228
+ def derive(
229
+ model: Model, *, source: Element, target: Element, owner: Element | None = None
230
+ ) -> DeriveRelationship:
231
+ """Record that requirement ``source`` derives from requirement ``target``."""
232
+ return _relate(model, DeriveRelationship, source, target, owner) # type: ignore[return-value]
233
+
234
+
235
+ def allocate(
236
+ model: Model, *, source: Element, target: Element, owner: Element | None = None
237
+ ) -> AllocateRelationship:
238
+ """Record that ``source`` is allocated to ``target`` (a part)."""
239
+ return _relate(model, AllocateRelationship, source, target, owner) # type: ignore[return-value]
@@ -0,0 +1,220 @@
1
+ """The Model container: element registry, identity, and ownership.
2
+
3
+ Ownership lives here (owner/owned maps keyed by element id), not on the
4
+ elements, mirroring how the Systems Modeling API keeps owning-relationship
5
+ records separate from element payloads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from collections.abc import Iterator, Sequence
12
+ from uuid import UUID
13
+
14
+ from sysml2kit.model.base import Element, Ref, Relationship
15
+
16
+ #: Namespace for stable UUIDv5 ids derived from qualified names.
17
+ STABLE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_DNS, "sysml2kit")
18
+
19
+
20
+ class Model:
21
+ """A set of elements with identity, ownership, and lookup."""
22
+
23
+ def __init__(self) -> None:
24
+ self.elements: dict[UUID, Element] = {}
25
+ self.owner: dict[UUID, UUID] = {}
26
+ self.owned: dict[UUID, list[UUID]] = {}
27
+ self.roots: list[UUID] = []
28
+
29
+ # ------------------------------------------------------------- mutation
30
+ def add(self, element: Element, owner: Element | UUID | None = None) -> Element:
31
+ """Register an element, optionally under an owner already in the model."""
32
+ eid = element.element_id
33
+ if eid in self.elements:
34
+ raise ValueError(f"duplicate element id {eid}")
35
+ self.elements[eid] = element
36
+ if owner is None:
37
+ self.roots.append(eid)
38
+ else:
39
+ oid = owner if isinstance(owner, UUID) else owner.element_id
40
+ if oid not in self.elements:
41
+ raise KeyError(f"owner {oid} is not in the model")
42
+ self.owner[eid] = oid
43
+ self.owned.setdefault(oid, []).append(eid)
44
+ return element
45
+
46
+ def remove(self, element: Element | UUID) -> None:
47
+ """Remove an element and reparent nothing: its owned elements become roots."""
48
+ eid = element if isinstance(element, UUID) else element.element_id
49
+ if eid not in self.elements:
50
+ raise KeyError(f"element {eid} is not in the model")
51
+ for child in self.owned.pop(eid, []):
52
+ del self.owner[child]
53
+ self.roots.append(child)
54
+ oid = self.owner.pop(eid, None)
55
+ if oid is None:
56
+ self.roots.remove(eid)
57
+ else:
58
+ self.owned[oid].remove(eid)
59
+ del self.elements[eid]
60
+
61
+ # --------------------------------------------------------------- lookup
62
+ def resolve(self, ref: Ref | UUID) -> Element:
63
+ """Return the element a ref (or id) points at."""
64
+ eid = ref.target if isinstance(ref, Ref) else ref
65
+ return self.elements[eid]
66
+
67
+ def owner_of(self, element: Element | UUID) -> Element | None:
68
+ """Return the owning element, or None for a root."""
69
+ eid = element if isinstance(element, UUID) else element.element_id
70
+ oid = self.owner.get(eid)
71
+ return self.elements[oid] if oid is not None else None
72
+
73
+ def owned_by(self, element: Element | UUID) -> list[Element]:
74
+ """Return the owned elements, in insertion order."""
75
+ eid = element if isinstance(element, UUID) else element.element_id
76
+ return [self.elements[cid] for cid in self.owned.get(eid, [])]
77
+
78
+ def qualified_name(self, element: Element | UUID) -> str:
79
+ """Return the ``::``-joined name path from the root to this element.
80
+
81
+ Unnamed elements contribute their kind and a positional index, so the
82
+ path is always defined (and usable for stable-id hashing).
83
+ """
84
+ eid = element if isinstance(element, UUID) else element.element_id
85
+ parts: list[str] = []
86
+ current: UUID | None = eid
87
+ while current is not None:
88
+ el = self.elements[current]
89
+ parts.append(el.declared_name or self._positional_name(current))
90
+ current = self.owner.get(current)
91
+ return "::".join(reversed(parts))
92
+
93
+ def _positional_name(self, eid: UUID) -> str:
94
+ el = self.elements[eid]
95
+ oid = self.owner.get(eid)
96
+ siblings = self.owned.get(oid, []) if oid is not None else self.roots
97
+ index = siblings.index(eid)
98
+ return f"{type(el).__name__}#{index}"
99
+
100
+ def find(
101
+ self,
102
+ *,
103
+ name: str | None = None,
104
+ kind: type[Element] | None = None,
105
+ ) -> list[Element]:
106
+ """Return elements matching a declared name and/or a class."""
107
+ out: list[Element] = []
108
+ for el in self.iter_elements(kind=kind):
109
+ if name is not None and el.declared_name != name:
110
+ continue
111
+ out.append(el)
112
+ return out
113
+
114
+ def find_by_qualified_name(self, qualified: str) -> Element | None:
115
+ """Return the element with this exact qualified name, if any."""
116
+ for eid in self.elements:
117
+ if self.qualified_name(eid) == qualified:
118
+ return self.elements[eid]
119
+ return None
120
+
121
+ def iter_elements(self, *, kind: type[Element] | None = None) -> Iterator[Element]:
122
+ """Iterate elements in ownership (depth-first) order."""
123
+ for eid in self._walk():
124
+ el = self.elements[eid]
125
+ if kind is None or isinstance(el, kind):
126
+ yield el
127
+
128
+ def _walk(self) -> Iterator[UUID]:
129
+ stack = list(reversed(self.roots))
130
+ while stack:
131
+ eid = stack.pop()
132
+ yield eid
133
+ stack.extend(reversed(self.owned.get(eid, [])))
134
+
135
+ def relationships(
136
+ self,
137
+ *,
138
+ kind: type[Relationship] | None = None,
139
+ source: Element | UUID | None = None,
140
+ target: Element | UUID | None = None,
141
+ ) -> list[Relationship]:
142
+ """Return relationships filtered by class and/or endpoint."""
143
+ sid = source.element_id if isinstance(source, Element) else source
144
+ tid = target.element_id if isinstance(target, Element) else target
145
+ out: list[Relationship] = []
146
+ for el in self.elements.values():
147
+ if not isinstance(el, Relationship):
148
+ continue
149
+ if kind is not None and not isinstance(el, kind):
150
+ continue
151
+ if sid is not None and el.source.target != sid:
152
+ continue
153
+ if tid is not None and el.target.target != tid:
154
+ continue
155
+ out.append(el)
156
+ return out
157
+
158
+ # ------------------------------------------------------------ integrity
159
+ def check_refs(self) -> list[tuple[UUID, str, UUID]]:
160
+ """Return (element_id, field_name, missing_target) for dangling refs."""
161
+ dangling: list[tuple[UUID, str, UUID]] = []
162
+ for el in self.elements.values():
163
+ for field, ref in self._refs_of(el):
164
+ if ref.target not in self.elements:
165
+ dangling.append((el.element_id, field, ref.target))
166
+ return dangling
167
+
168
+ @staticmethod
169
+ def _refs_of(element: Element) -> list[tuple[str, Ref]]:
170
+ refs: list[tuple[str, Ref]] = []
171
+ for field in type(element).model_fields:
172
+ value = getattr(element, field)
173
+ if isinstance(value, Ref):
174
+ refs.append((field, value))
175
+ elif isinstance(value, Sequence) and not isinstance(value, str | bytes):
176
+ refs.extend((field, item) for item in value if isinstance(item, Ref))
177
+ return refs
178
+
179
+ def assign_stable_ids(self) -> dict[UUID, UUID]:
180
+ """Rewrite every element id as a UUIDv5 hash of its qualified name.
181
+
182
+ Returns the old-to-new id mapping. Refs, ownership maps, and roots are
183
+ remapped in place. Run this before committing generated interchange
184
+ files so regeneration produces stable diffs.
185
+ """
186
+ mapping = {
187
+ eid: uuid.uuid5(STABLE_ID_NAMESPACE, self.qualified_name(eid)) for eid in self.elements
188
+ }
189
+ if len(set(mapping.values())) != len(mapping):
190
+ raise ValueError(
191
+ "duplicate qualified names; stable ids need unique name paths "
192
+ "(rename the clashing siblings, see validation rule S2K003)"
193
+ )
194
+ new_elements: dict[UUID, Element] = {}
195
+ for eid, el in self.elements.items():
196
+ updates: dict[str, object] = {"element_id": mapping[eid]}
197
+ for field, ref in self._refs_of(el):
198
+ value = getattr(el, field)
199
+ if isinstance(value, Ref):
200
+ updates[field] = Ref(target=mapping.get(ref.target, ref.target))
201
+ for field in type(el).model_fields:
202
+ value = getattr(el, field)
203
+ if (
204
+ isinstance(value, Sequence)
205
+ and not isinstance(value, str | bytes)
206
+ and any(isinstance(item, Ref) for item in value)
207
+ ):
208
+ updates[field] = [
209
+ Ref(target=mapping.get(item.target, item.target))
210
+ if isinstance(item, Ref)
211
+ else item
212
+ for item in value
213
+ ]
214
+ new_el = el.model_copy(update=updates)
215
+ new_elements[new_el.element_id] = new_el
216
+ self.elements = new_elements
217
+ self.owner = {mapping[k]: mapping[v] for k, v in self.owner.items()}
218
+ self.owned = {mapping[k]: [mapping[c] for c in v] for k, v in self.owned.items()}
219
+ self.roots = [mapping[r] for r in self.roots]
220
+ return mapping
@@ -0,0 +1,19 @@
1
+ """Metadata annotation elements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from sysml2kit.model.base import Element, Ref
8
+
9
+
10
+ class MetadataDefinition(Element):
11
+ """A reusable definition of a metadata annotation kind."""
12
+
13
+
14
+ class MetadataUsage(Element):
15
+ """A metadata annotation on another element, as a key-value mapping."""
16
+
17
+ definition: Ref | None = None
18
+ annotated: Ref | None = None
19
+ values: dict[str, str | float | int | bool] = Field(default_factory=dict)
@@ -0,0 +1,26 @@
1
+ """Reified traceability relationships.
2
+
3
+ The spec models some of these as membership forms; sysml2kit reifies each as
4
+ a first-class relationship with ``source``/``target`` refs (a documented
5
+ deviation, see SPEC.md).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sysml2kit.model.base import Relationship
11
+
12
+
13
+ class SatisfyRelationship(Relationship):
14
+ """``source`` (a part or design element) satisfies ``target`` (a requirement)."""
15
+
16
+
17
+ class VerifyRelationship(Relationship):
18
+ """``source`` (an analysis or test case) verifies ``target`` (a requirement)."""
19
+
20
+
21
+ class DeriveRelationship(Relationship):
22
+ """``source`` (a requirement) is derived from ``target`` (a requirement)."""
23
+
24
+
25
+ class AllocateRelationship(Relationship):
26
+ """``source`` (a function or requirement) is allocated to ``target`` (a part)."""
@@ -0,0 +1,23 @@
1
+ """Requirement and constraint elements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sysml2kit.model.base import Element, Ref
6
+
7
+
8
+ class RequirementDefinition(Element):
9
+ """A reusable definition of a requirement kind."""
10
+
11
+
12
+ class RequirementUsage(Element):
13
+ """A requirement occurrence with an optional subject and statement text."""
14
+
15
+ definition: Ref | None = None
16
+ subject: Ref | None = None
17
+ text: str | None = None
18
+
19
+
20
+ class ConstraintUsage(Element):
21
+ """A constraint; the expression is an opaque string in v0.1."""
22
+
23
+ expression: str | None = None
@@ -0,0 +1,60 @@
1
+ """Structural elements: packages, parts, ports, interfaces, connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from sysml2kit.model.base import Element, Ref
8
+ from sysml2kit.model.values import AttributeValue
9
+
10
+
11
+ class Package(Element):
12
+ """A namespace for other elements; maps to a top-level ``.sysml`` file."""
13
+
14
+ imports: list[str] = Field(default_factory=list)
15
+
16
+
17
+ class PartDefinition(Element):
18
+ """A reusable definition of a system component kind."""
19
+
20
+
21
+ class PartUsage(Element):
22
+ """A component occurrence, optionally typed by a :class:`PartDefinition`."""
23
+
24
+ definition: Ref | None = None
25
+ multiplicity: str | None = None
26
+
27
+
28
+ class PortDefinition(Element):
29
+ """A reusable definition of an interaction point kind."""
30
+
31
+
32
+ class PortUsage(Element):
33
+ """A port occurrence on a part, optionally typed by a :class:`PortDefinition`."""
34
+
35
+ definition: Ref | None = None
36
+
37
+
38
+ class InterfaceDefinition(Element):
39
+ """A definition of how two ports connect."""
40
+
41
+
42
+ class ConnectionUsage(Element):
43
+ """A connection between two port (or part) ends."""
44
+
45
+ definition: Ref | None = None
46
+ source: Ref | None = None
47
+ target: Ref | None = None
48
+
49
+
50
+ class AttributeDefinition(Element):
51
+ """A reusable definition of a value kind, e.g. a quantity with a unit."""
52
+
53
+ unit: str | None = None
54
+
55
+
56
+ class AttributeUsage(Element):
57
+ """A value occurrence, optionally typed and optionally holding a value."""
58
+
59
+ definition: Ref | None = None
60
+ value: AttributeValue | None = None
@@ -0,0 +1,31 @@
1
+ """Attribute values with units and provenance.
2
+
3
+ Follows the ``Assumption`` pattern from spacedc-mdao: a number in a model
4
+ should say where it came from. Units are stored as text (what the textual
5
+ notation carries, e.g. ``"dBW"``); ``sysml2kit.units`` checks them against
6
+ pint during validation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+
16
+ class AttributeValue(BaseModel):
17
+ """A literal value with optional unit text and provenance."""
18
+
19
+ model_config = ConfigDict(frozen=True)
20
+
21
+ value: float | int | str | bool | None = None
22
+ unit: str | None = None
23
+ source: str | None = None
24
+ confidence: Literal["low", "medium", "high"] | None = None
25
+
26
+ def render(self) -> str:
27
+ """Render as textual-notation value text, e.g. ``52.0 [dBW]``."""
28
+ body = f'"{self.value}"' if isinstance(self.value, str) else str(self.value)
29
+ if self.unit:
30
+ return f"{body} [{self.unit}]"
31
+ return body
sysml2kit/py.typed ADDED
File without changes
sysml2kit/units.py ADDED
@@ -0,0 +1,60 @@
1
+ """Unit-string helpers backed by pint.
2
+
3
+ Models store units as text (round-trip fidelity with the textual notation);
4
+ these helpers check and convert them. A few decibel spellings common in
5
+ engineering practice are registered on top of pint's defaults.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from typing import Any
12
+
13
+ import pint
14
+
15
+ _EXTRA_DEFINITIONS = (
16
+ # Referenced-quantity decibels: dimensionally plain decibels; the reference
17
+ # (isotropic antenna, dipole, carrier, per-kelvin) is bookkeeping the model
18
+ # keeps in the unit text.
19
+ "dBi = decibel",
20
+ "dBd = decibel",
21
+ "dBc = decibel",
22
+ "dBK = decibel",
23
+ )
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def registry() -> Any:
28
+ """Return the shared pint unit registry (created on first use)."""
29
+ reg: Any = pint.UnitRegistry()
30
+ for definition in _EXTRA_DEFINITIONS:
31
+ reg.define(definition)
32
+ return reg
33
+
34
+
35
+ def parse_unit(text: str) -> Any:
36
+ """Parse unit text into a pint unit; raises ValueError on unknown units."""
37
+ try:
38
+ return registry().Unit(text)
39
+ except Exception as exc:
40
+ raise ValueError(f"unparseable unit {text!r}: {exc}") from exc
41
+
42
+
43
+ def is_valid_unit(text: str) -> bool:
44
+ """Return whether pint can parse this unit text."""
45
+ try:
46
+ parse_unit(text)
47
+ except ValueError:
48
+ return False
49
+ return True
50
+
51
+
52
+ def convert(value: float, from_unit: str, to_unit: str) -> float:
53
+ """Convert a value between two unit texts."""
54
+ quantity = registry().Quantity(value, parse_unit(from_unit))
55
+ return float(quantity.to(parse_unit(to_unit)).magnitude)
56
+
57
+
58
+ def check_dimensionality(unit_a: str, unit_b: str) -> bool:
59
+ """Return whether two unit texts share a dimensionality."""
60
+ return bool(parse_unit(unit_a).dimensionality == parse_unit(unit_b).dimensionality)