Skip to content

API reference

Model and elements

A set of elements with identity, ownership, and lookup.

Source code in src/sysml2kit/model/container.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
class Model:
    """A set of elements with identity, ownership, and lookup."""

    def __init__(self) -> None:
        self.elements: dict[UUID, Element] = {}
        self.owner: dict[UUID, UUID] = {}
        self.owned: dict[UUID, list[UUID]] = {}
        self.roots: list[UUID] = []

    # ------------------------------------------------------------- mutation
    def add(self, element: Element, owner: Element | UUID | None = None) -> Element:
        """Register an element, optionally under an owner already in the model."""
        eid = element.element_id
        if eid in self.elements:
            raise ValueError(f"duplicate element id {eid}")
        self.elements[eid] = element
        if owner is None:
            self.roots.append(eid)
        else:
            oid = owner if isinstance(owner, UUID) else owner.element_id
            if oid not in self.elements:
                raise KeyError(f"owner {oid} is not in the model")
            self.owner[eid] = oid
            self.owned.setdefault(oid, []).append(eid)
        return element

    def remove(self, element: Element | UUID) -> None:
        """Remove an element and reparent nothing: its owned elements become roots."""
        eid = element if isinstance(element, UUID) else element.element_id
        if eid not in self.elements:
            raise KeyError(f"element {eid} is not in the model")
        for child in self.owned.pop(eid, []):
            del self.owner[child]
            self.roots.append(child)
        oid = self.owner.pop(eid, None)
        if oid is None:
            self.roots.remove(eid)
        else:
            self.owned[oid].remove(eid)
        del self.elements[eid]

    # --------------------------------------------------------------- lookup
    def resolve(self, ref: Ref | UUID) -> Element:
        """Return the element a ref (or id) points at."""
        eid = ref.target if isinstance(ref, Ref) else ref
        return self.elements[eid]

    def owner_of(self, element: Element | UUID) -> Element | None:
        """Return the owning element, or None for a root."""
        eid = element if isinstance(element, UUID) else element.element_id
        oid = self.owner.get(eid)
        return self.elements[oid] if oid is not None else None

    def owned_by(self, element: Element | UUID) -> list[Element]:
        """Return the owned elements, in insertion order."""
        eid = element if isinstance(element, UUID) else element.element_id
        return [self.elements[cid] for cid in self.owned.get(eid, [])]

    def qualified_name(self, element: Element | UUID) -> str:
        """Return the ``::``-joined name path from the root to this element.

        Unnamed elements contribute their kind and a positional index, so the
        path is always defined (and usable for stable-id hashing).
        """
        eid = element if isinstance(element, UUID) else element.element_id
        parts: list[str] = []
        current: UUID | None = eid
        while current is not None:
            el = self.elements[current]
            parts.append(el.declared_name or self._positional_name(current))
            current = self.owner.get(current)
        return "::".join(reversed(parts))

    def _positional_name(self, eid: UUID) -> str:
        el = self.elements[eid]
        if isinstance(el, Relationship):
            # Endpoint-based, so the name survives sibling reordering (which a
            # sorted interchange round trip causes) and keeps stable ids stable.
            source = self.elements.get(el.source.target)
            target = self.elements.get(el.target.target)
            source_label = source.label if source is not None else str(el.source.target)
            target_label = target.label if target is not None else str(el.target.target)
            return f"{type(el).__name__}({source_label}->{target_label})"
        oid = self.owner.get(eid)
        siblings = self.owned.get(oid, []) if oid is not None else self.roots
        index = siblings.index(eid)
        return f"{type(el).__name__}#{index}"

    def find(
        self,
        *,
        name: str | None = None,
        kind: type[Element] | None = None,
    ) -> list[Element]:
        """Return elements matching a declared name and/or a class."""
        out: list[Element] = []
        for el in self.iter_elements(kind=kind):
            if name is not None and el.declared_name != name:
                continue
            out.append(el)
        return out

    def find_by_qualified_name(self, qualified: str) -> Element | None:
        """Return the element with this exact qualified name, if any."""
        for eid in self.elements:
            if self.qualified_name(eid) == qualified:
                return self.elements[eid]
        return None

    def iter_elements(self, *, kind: type[Element] | None = None) -> Iterator[Element]:
        """Iterate elements in ownership (depth-first) order."""
        for eid in self._walk():
            el = self.elements[eid]
            if kind is None or isinstance(el, kind):
                yield el

    def _walk(self) -> Iterator[UUID]:
        stack = list(reversed(self.roots))
        while stack:
            eid = stack.pop()
            yield eid
            stack.extend(reversed(self.owned.get(eid, [])))

    def relationships(
        self,
        *,
        kind: type[Relationship] | None = None,
        source: Element | UUID | None = None,
        target: Element | UUID | None = None,
    ) -> list[Relationship]:
        """Return relationships filtered by class and/or endpoint."""
        sid = source.element_id if isinstance(source, Element) else source
        tid = target.element_id if isinstance(target, Element) else target
        out: list[Relationship] = []
        for el in self.elements.values():
            if not isinstance(el, Relationship):
                continue
            if kind is not None and not isinstance(el, kind):
                continue
            if sid is not None and el.source.target != sid:
                continue
            if tid is not None and el.target.target != tid:
                continue
            out.append(el)
        return out

    # ------------------------------------------------------------ integrity
    def check_refs(self) -> list[tuple[UUID, str, UUID]]:
        """Return (element_id, field_name, missing_target) for dangling refs."""
        dangling: list[tuple[UUID, str, UUID]] = []
        for el in self.elements.values():
            for field, ref in self._refs_of(el):
                if ref.target not in self.elements:
                    dangling.append((el.element_id, field, ref.target))
        return dangling

    @staticmethod
    def _refs_of(element: Element) -> list[tuple[str, Ref]]:
        refs: list[tuple[str, Ref]] = []
        for field in type(element).model_fields:
            value = getattr(element, field)
            if isinstance(value, Ref):
                refs.append((field, value))
            elif isinstance(value, Sequence) and not isinstance(value, str | bytes):
                refs.extend((field, item) for item in value if isinstance(item, Ref))
        return refs

    def assign_stable_ids(self) -> dict[UUID, UUID]:
        """Rewrite every element id as a UUIDv5 hash of its qualified name.

        Returns the old-to-new id mapping. Refs, ownership maps, and roots are
        remapped in place. Run this before committing generated interchange
        files so regeneration produces stable diffs.
        """
        mapping = {
            eid: uuid.uuid5(STABLE_ID_NAMESPACE, self.qualified_name(eid)) for eid in self.elements
        }
        if len(set(mapping.values())) != len(mapping):
            raise ValueError(
                "duplicate qualified names; stable ids need unique name paths "
                "(rename the clashing siblings, see validation rule S2K003)"
            )
        new_elements: dict[UUID, Element] = {}
        for eid, el in self.elements.items():
            updates: dict[str, object] = {"element_id": mapping[eid]}
            for field, ref in self._refs_of(el):
                value = getattr(el, field)
                if isinstance(value, Ref):
                    updates[field] = Ref(target=mapping.get(ref.target, ref.target))
            for field in type(el).model_fields:
                value = getattr(el, field)
                if (
                    isinstance(value, Sequence)
                    and not isinstance(value, str | bytes)
                    and any(isinstance(item, Ref) for item in value)
                ):
                    updates[field] = [
                        Ref(target=mapping.get(item.target, item.target))
                        if isinstance(item, Ref)
                        else item
                        for item in value
                    ]
            new_el = el.model_copy(update=updates)
            new_elements[new_el.element_id] = new_el
        self.elements = new_elements
        self.owner = {mapping[k]: mapping[v] for k, v in self.owner.items()}
        self.owned = {mapping[k]: [mapping[c] for c in v] for k, v in self.owned.items()}
        self.roots = [mapping[r] for r in self.roots]
        return mapping

add(element, owner=None)

Register an element, optionally under an owner already in the model.

Source code in src/sysml2kit/model/container.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def add(self, element: Element, owner: Element | UUID | None = None) -> Element:
    """Register an element, optionally under an owner already in the model."""
    eid = element.element_id
    if eid in self.elements:
        raise ValueError(f"duplicate element id {eid}")
    self.elements[eid] = element
    if owner is None:
        self.roots.append(eid)
    else:
        oid = owner if isinstance(owner, UUID) else owner.element_id
        if oid not in self.elements:
            raise KeyError(f"owner {oid} is not in the model")
        self.owner[eid] = oid
        self.owned.setdefault(oid, []).append(eid)
    return element

assign_stable_ids()

Rewrite every element id as a UUIDv5 hash of its qualified name.

Returns the old-to-new id mapping. Refs, ownership maps, and roots are remapped in place. Run this before committing generated interchange files so regeneration produces stable diffs.

Source code in src/sysml2kit/model/container.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def assign_stable_ids(self) -> dict[UUID, UUID]:
    """Rewrite every element id as a UUIDv5 hash of its qualified name.

    Returns the old-to-new id mapping. Refs, ownership maps, and roots are
    remapped in place. Run this before committing generated interchange
    files so regeneration produces stable diffs.
    """
    mapping = {
        eid: uuid.uuid5(STABLE_ID_NAMESPACE, self.qualified_name(eid)) for eid in self.elements
    }
    if len(set(mapping.values())) != len(mapping):
        raise ValueError(
            "duplicate qualified names; stable ids need unique name paths "
            "(rename the clashing siblings, see validation rule S2K003)"
        )
    new_elements: dict[UUID, Element] = {}
    for eid, el in self.elements.items():
        updates: dict[str, object] = {"element_id": mapping[eid]}
        for field, ref in self._refs_of(el):
            value = getattr(el, field)
            if isinstance(value, Ref):
                updates[field] = Ref(target=mapping.get(ref.target, ref.target))
        for field in type(el).model_fields:
            value = getattr(el, field)
            if (
                isinstance(value, Sequence)
                and not isinstance(value, str | bytes)
                and any(isinstance(item, Ref) for item in value)
            ):
                updates[field] = [
                    Ref(target=mapping.get(item.target, item.target))
                    if isinstance(item, Ref)
                    else item
                    for item in value
                ]
        new_el = el.model_copy(update=updates)
        new_elements[new_el.element_id] = new_el
    self.elements = new_elements
    self.owner = {mapping[k]: mapping[v] for k, v in self.owner.items()}
    self.owned = {mapping[k]: [mapping[c] for c in v] for k, v in self.owned.items()}
    self.roots = [mapping[r] for r in self.roots]
    return mapping

check_refs()

Return (element_id, field_name, missing_target) for dangling refs.

Source code in src/sysml2kit/model/container.py
167
168
169
170
171
172
173
174
def check_refs(self) -> list[tuple[UUID, str, UUID]]:
    """Return (element_id, field_name, missing_target) for dangling refs."""
    dangling: list[tuple[UUID, str, UUID]] = []
    for el in self.elements.values():
        for field, ref in self._refs_of(el):
            if ref.target not in self.elements:
                dangling.append((el.element_id, field, ref.target))
    return dangling

find(*, name=None, kind=None)

Return elements matching a declared name and/or a class.

Source code in src/sysml2kit/model/container.py
108
109
110
111
112
113
114
115
116
117
118
119
120
def find(
    self,
    *,
    name: str | None = None,
    kind: type[Element] | None = None,
) -> list[Element]:
    """Return elements matching a declared name and/or a class."""
    out: list[Element] = []
    for el in self.iter_elements(kind=kind):
        if name is not None and el.declared_name != name:
            continue
        out.append(el)
    return out

find_by_qualified_name(qualified)

Return the element with this exact qualified name, if any.

Source code in src/sysml2kit/model/container.py
122
123
124
125
126
127
def find_by_qualified_name(self, qualified: str) -> Element | None:
    """Return the element with this exact qualified name, if any."""
    for eid in self.elements:
        if self.qualified_name(eid) == qualified:
            return self.elements[eid]
    return None

iter_elements(*, kind=None)

Iterate elements in ownership (depth-first) order.

Source code in src/sysml2kit/model/container.py
129
130
131
132
133
134
def iter_elements(self, *, kind: type[Element] | None = None) -> Iterator[Element]:
    """Iterate elements in ownership (depth-first) order."""
    for eid in self._walk():
        el = self.elements[eid]
        if kind is None or isinstance(el, kind):
            yield el

owned_by(element)

Return the owned elements, in insertion order.

Source code in src/sysml2kit/model/container.py
73
74
75
76
def owned_by(self, element: Element | UUID) -> list[Element]:
    """Return the owned elements, in insertion order."""
    eid = element if isinstance(element, UUID) else element.element_id
    return [self.elements[cid] for cid in self.owned.get(eid, [])]

owner_of(element)

Return the owning element, or None for a root.

Source code in src/sysml2kit/model/container.py
67
68
69
70
71
def owner_of(self, element: Element | UUID) -> Element | None:
    """Return the owning element, or None for a root."""
    eid = element if isinstance(element, UUID) else element.element_id
    oid = self.owner.get(eid)
    return self.elements[oid] if oid is not None else None

qualified_name(element)

Return the ::-joined name path from the root to this element.

Unnamed elements contribute their kind and a positional index, so the path is always defined (and usable for stable-id hashing).

Source code in src/sysml2kit/model/container.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def qualified_name(self, element: Element | UUID) -> str:
    """Return the ``::``-joined name path from the root to this element.

    Unnamed elements contribute their kind and a positional index, so the
    path is always defined (and usable for stable-id hashing).
    """
    eid = element if isinstance(element, UUID) else element.element_id
    parts: list[str] = []
    current: UUID | None = eid
    while current is not None:
        el = self.elements[current]
        parts.append(el.declared_name or self._positional_name(current))
        current = self.owner.get(current)
    return "::".join(reversed(parts))

relationships(*, kind=None, source=None, target=None)

Return relationships filtered by class and/or endpoint.

Source code in src/sysml2kit/model/container.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def relationships(
    self,
    *,
    kind: type[Relationship] | None = None,
    source: Element | UUID | None = None,
    target: Element | UUID | None = None,
) -> list[Relationship]:
    """Return relationships filtered by class and/or endpoint."""
    sid = source.element_id if isinstance(source, Element) else source
    tid = target.element_id if isinstance(target, Element) else target
    out: list[Relationship] = []
    for el in self.elements.values():
        if not isinstance(el, Relationship):
            continue
        if kind is not None and not isinstance(el, kind):
            continue
        if sid is not None and el.source.target != sid:
            continue
        if tid is not None and el.target.target != tid:
            continue
        out.append(el)
    return out

remove(element)

Remove an element and reparent nothing: its owned elements become roots.

Source code in src/sysml2kit/model/container.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def remove(self, element: Element | UUID) -> None:
    """Remove an element and reparent nothing: its owned elements become roots."""
    eid = element if isinstance(element, UUID) else element.element_id
    if eid not in self.elements:
        raise KeyError(f"element {eid} is not in the model")
    for child in self.owned.pop(eid, []):
        del self.owner[child]
        self.roots.append(child)
    oid = self.owner.pop(eid, None)
    if oid is None:
        self.roots.remove(eid)
    else:
        self.owned[oid].remove(eid)
    del self.elements[eid]

resolve(ref)

Return the element a ref (or id) points at.

Source code in src/sysml2kit/model/container.py
62
63
64
65
def resolve(self, ref: Ref | UUID) -> Element:
    """Return the element a ref (or id) points at."""
    eid = ref.target if isinstance(ref, Ref) else ref
    return self.elements[eid]

Base element classes and the cross-reference type.

Every cross-reference between elements is a :class:Ref (a UUID wrapper), never a direct Python object reference, so any element serializes on its own and maps 1:1 onto the Systems Modeling API JSON {"@id": ...} form. Ownership is not stored on elements either; the Model container keeps it.

Element

Bases: BaseModel

Common base for every model element in the pragmatic profile.

Source code in src/sysml2kit/model/base.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Element(BaseModel):
    """Common base for every model element in the pragmatic profile."""

    model_config = ConfigDict(validate_assignment=True)

    element_id: UUID = Field(default_factory=uuid4)
    declared_name: str | None = None
    declared_short_name: str | None = None
    doc: str | None = None

    @property
    def label(self) -> str:
        """A human-readable identifier: name, short name, or the id."""
        return self.declared_name or self.declared_short_name or str(self.element_id)

label property

A human-readable identifier: name, short name, or the id.

OpaqueElement

Bases: Element

An element outside the pragmatic profile, preserved verbatim.

raw holds the original JSON interchange record; it re-exports unchanged, so reading and writing a model does not drop content the profile has no class for.

Source code in src/sysml2kit/model/base.py
64
65
66
67
68
69
70
71
72
73
class OpaqueElement(Element):
    """An element outside the pragmatic profile, preserved verbatim.

    ``raw`` holds the original JSON interchange record; it re-exports
    unchanged, so reading and writing a model does not drop content the
    profile has no class for.
    """

    type_name: str
    raw: dict[str, Any] = Field(default_factory=dict)

Ref

Bases: BaseModel

Reference to another element by id.

Source code in src/sysml2kit/model/base.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Ref(BaseModel):
    """Reference to another element by id."""

    model_config = ConfigDict(frozen=True)

    target: UUID

    @classmethod
    def to(cls, element: Element | UUID | Ref) -> Ref:
        """Build a Ref from an element, a UUID, or another Ref."""
        if isinstance(element, Ref):
            return element
        if isinstance(element, UUID):
            return cls(target=element)
        return cls(target=element.element_id)

    def resolve(self, model: Model) -> Element:
        """Return the referenced element, raising KeyError if absent."""
        return model.elements[self.target]

resolve(model)

Return the referenced element, raising KeyError if absent.

Source code in src/sysml2kit/model/base.py
36
37
38
def resolve(self, model: Model) -> Element:
    """Return the referenced element, raising KeyError if absent."""
    return model.elements[self.target]

to(element) classmethod

Build a Ref from an element, a UUID, or another Ref.

Source code in src/sysml2kit/model/base.py
27
28
29
30
31
32
33
34
@classmethod
def to(cls, element: Element | UUID | Ref) -> Ref:
    """Build a Ref from an element, a UUID, or another Ref."""
    if isinstance(element, Ref):
        return element
    if isinstance(element, UUID):
        return cls(target=element)
    return cls(target=element.element_id)

Relationship

Bases: Element

Common base for reified relationships with a source and a target.

Source code in src/sysml2kit/model/base.py
57
58
59
60
61
class Relationship(Element):
    """Common base for reified relationships with a source and a target."""

    source: Ref
    target: Ref

Builder

Fluent authoring helpers: the API humans and agents actually type.

Each helper constructs an element, registers it in the model under the given owner, and returns it. The raw element classes stay the interchange-faithful layer; nothing here adds state the classes lack.

allocate(model, *, source, target, owner=None)

Record that source is allocated to target (a part).

Source code in src/sysml2kit/model/builder.py
263
264
265
266
267
def allocate(
    model: Model, *, source: Element, target: Element, owner: Element | None = None
) -> AllocateRelationship:
    """Record that ``source`` is allocated to ``target`` (a part)."""
    return _relate(model, AllocateRelationship, source, target, owner)  # type: ignore[return-value]

analysis(model, name, *, owner=None, subject=None, objective=None, definition=None)

Create an analysis case usage.

Source code in src/sysml2kit/model/builder.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def analysis(
    model: Model,
    name: str,
    *,
    owner: Element | None = None,
    subject: Element | None = None,
    objective: str | None = None,
    definition: Element | None = None,
) -> AnalysisCaseUsage:
    """Create an analysis case usage."""
    usage = AnalysisCaseUsage(
        declared_name=name,
        subject=Ref.to(subject) if subject else None,
        objective=objective,
        definition=Ref.to(definition) if definition else None,
    )
    return model.add(usage, owner=owner)  # type: ignore[return-value]

analysis_def(model, name, *, owner=None, doc=None)

Create an analysis case definition.

Source code in src/sysml2kit/model/builder.py
163
164
165
166
167
def analysis_def(
    model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
) -> AnalysisCaseDefinition:
    """Create an analysis case definition."""
    return model.add(AnalysisCaseDefinition(declared_name=name, doc=doc), owner=owner)  # type: ignore[return-value]

attr(model, name, value=None, *, owner=None, unit=None, definition=None, source=None)

Create an attribute usage holding a value with optional unit and provenance.

Source code in src/sysml2kit/model/builder.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def attr(
    model: Model,
    name: str,
    value: float | str | bool | None = None,
    *,
    owner: Element | None = None,
    unit: str | None = None,
    definition: Element | None = None,
    source: str | None = None,
) -> AttributeUsage:
    """Create an attribute usage holding a value with optional unit and provenance."""
    usage = AttributeUsage(
        declared_name=name,
        definition=Ref.to(definition) if definition else None,
        value=AttributeValue(value=value, unit=unit, source=source) if value is not None else None,
    )
    return model.add(usage, owner=owner)  # type: ignore[return-value]

attr_def(model, name, *, owner=None, unit=None, doc=None)

Create an attribute definition, optionally with a default unit.

Source code in src/sysml2kit/model/builder.py
102
103
104
105
106
107
108
109
110
111
112
113
def attr_def(
    model: Model,
    name: str,
    *,
    owner: Element | None = None,
    unit: str | None = None,
    doc: str | None = None,
) -> AttributeDefinition:
    """Create an attribute definition, optionally with a default unit."""
    return model.add(  # type: ignore[return-value]
        AttributeDefinition(declared_name=name, unit=unit, doc=doc), owner=owner
    )

connect(model, source, target, *, owner=None, name=None)

Create a connection between two ports (or parts).

Source code in src/sysml2kit/model/builder.py
89
90
91
92
93
94
95
96
97
98
99
def connect(
    model: Model,
    source: Element,
    target: Element,
    *,
    owner: Element | None = None,
    name: str | None = None,
) -> ConnectionUsage:
    """Create a connection between two ports (or parts)."""
    usage = ConnectionUsage(declared_name=name, source=Ref.to(source), target=Ref.to(target))
    return model.add(usage, owner=owner)  # type: ignore[return-value]

derive(model, *, source, target, owner=None)

Record that requirement source derives from requirement target.

Source code in src/sysml2kit/model/builder.py
256
257
258
259
260
def derive(
    model: Model, *, source: Element, target: Element, owner: Element | None = None
) -> DeriveRelationship:
    """Record that requirement ``source`` derives from requirement ``target``."""
    return _relate(model, DeriveRelationship, source, target, owner)  # type: ignore[return-value]

metadata(model, annotated, values, *, owner=None, name=None, definition=None)

Attach a key-value metadata annotation to an element.

Default ownership is the annotated element's owner (package level): the about reference carries the attachment, and package-level placement is what survives the textual notation (metadata inside definition bodies is dropped by the parser). Passing definition types the usage by a metadata def, which lets sibling annotations carry distinct names (a fidelity ladder) while sharing one annotation kind.

Source code in src/sysml2kit/model/builder.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def metadata(
    model: Model,
    annotated: Element,
    values: dict[str, str | float | int | bool],
    *,
    owner: Element | None = None,
    name: str | None = None,
    definition: Element | None = None,
) -> MetadataUsage:
    """Attach a key-value metadata annotation to an element.

    Default ownership is the annotated element's owner (package level): the
    ``about`` reference carries the attachment, and package-level placement
    is what survives the textual notation (metadata inside definition bodies
    is dropped by the parser). Passing ``definition`` types the usage by a
    ``metadata def``, which lets sibling annotations carry distinct names
    (a fidelity ladder) while sharing one annotation kind.
    """
    usage = MetadataUsage(
        declared_name=name,
        annotated=Ref.to(annotated),
        values=dict(values),
        definition=Ref.to(definition) if definition is not None else None,
    )
    default_owner = model.owner_of(annotated) or annotated
    return model.add(usage, owner=owner if owner is not None else default_owner)  # type: ignore[return-value]

metadata_def(model, name, *, owner=None, doc=None)

Add a reusable metadata annotation kind (metadata def).

Source code in src/sysml2kit/model/builder.py
217
218
219
220
221
222
223
224
225
226
227
def metadata_def(
    model: Model,
    name: str,
    *,
    owner: Element | None = None,
    doc: str | None = None,
) -> MetadataDefinition:
    """Add a reusable metadata annotation kind (``metadata def``)."""
    return model.add(  # type: ignore[return-value]
        MetadataDefinition(declared_name=name, doc=doc), owner=owner
    )

part(model, name, *, owner=None, definition=None, multiplicity=None, doc=None)

Create a part usage, optionally typed by a part definition.

Source code in src/sysml2kit/model/builder.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def part(
    model: Model,
    name: str,
    *,
    owner: Element | None = None,
    definition: Element | None = None,
    multiplicity: str | None = None,
    doc: str | None = None,
) -> PartUsage:
    """Create a part usage, optionally typed by a part definition."""
    usage = PartUsage(
        declared_name=name,
        definition=Ref.to(definition) if definition else None,
        multiplicity=multiplicity,
        doc=doc,
    )
    return model.add(usage, owner=owner)  # type: ignore[return-value]

part_def(model, name, *, owner=None, doc=None)

Create a part definition.

Source code in src/sysml2kit/model/builder.py
41
42
43
44
45
def part_def(
    model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
) -> PartDefinition:
    """Create a part definition."""
    return model.add(PartDefinition(declared_name=name, doc=doc), owner=owner)  # type: ignore[return-value]

pkg(model, name, *, owner=None, doc=None)

Create a package.

Source code in src/sysml2kit/model/builder.py
34
35
36
37
38
def pkg(
    model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
) -> Package:
    """Create a package."""
    return model.add(Package(declared_name=name, doc=doc), owner=owner)  # type: ignore[return-value]

port(model, name, *, owner=None, definition=None)

Create a port usage on a part.

Source code in src/sysml2kit/model/builder.py
74
75
76
77
78
79
80
81
82
83
84
85
86
def port(
    model: Model,
    name: str,
    *,
    owner: Element | None = None,
    definition: Element | None = None,
) -> PortUsage:
    """Create a port usage on a part."""
    usage = PortUsage(
        declared_name=name,
        definition=Ref.to(definition) if definition else None,
    )
    return model.add(usage, owner=owner)  # type: ignore[return-value]

port_def(model, name, *, owner=None, doc=None)

Create a port definition.

Source code in src/sysml2kit/model/builder.py
67
68
69
70
71
def port_def(
    model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
) -> PortDefinition:
    """Create a port definition."""
    return model.add(PortDefinition(declared_name=name, doc=doc), owner=owner)  # type: ignore[return-value]

req(model, short_name, name, *, owner=None, text=None, subject=None, definition=None)

Create a requirement usage; short_name is the requirement id (e.g. REQ-001).

Source code in src/sysml2kit/model/builder.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def req(
    model: Model,
    short_name: str,
    name: str,
    *,
    owner: Element | None = None,
    text: str | None = None,
    subject: Element | None = None,
    definition: Element | None = None,
) -> RequirementUsage:
    """Create a requirement usage; ``short_name`` is the requirement id (e.g. REQ-001)."""
    usage = RequirementUsage(
        declared_short_name=short_name,
        declared_name=name,
        text=text,
        subject=Ref.to(subject) if subject else None,
        definition=Ref.to(definition) if definition else None,
    )
    return model.add(usage, owner=owner)  # type: ignore[return-value]

req_def(model, name, *, owner=None, doc=None)

Create a requirement definition.

Source code in src/sysml2kit/model/builder.py
135
136
137
138
139
def req_def(
    model: Model, name: str, *, owner: Element | None = None, doc: str | None = None
) -> RequirementDefinition:
    """Create a requirement definition."""
    return model.add(RequirementDefinition(declared_name=name, doc=doc), owner=owner)  # type: ignore[return-value]

satisfy(model, *, source, target, owner=None)

Record that source (a design element) satisfies target (a requirement).

Source code in src/sysml2kit/model/builder.py
242
243
244
245
246
def satisfy(
    model: Model, *, source: Element, target: Element, owner: Element | None = None
) -> SatisfyRelationship:
    """Record that ``source`` (a design element) satisfies ``target`` (a requirement)."""
    return _relate(model, SatisfyRelationship, source, target, owner)  # type: ignore[return-value]

verify(model, *, source, target, owner=None)

Record that source (an analysis/test) verifies target (a requirement).

Source code in src/sysml2kit/model/builder.py
249
250
251
252
253
def verify(
    model: Model, *, source: Element, target: Element, owner: Element | None = None
) -> VerifyRelationship:
    """Record that ``source`` (an analysis/test) verifies ``target`` (a requirement)."""
    return _relate(model, VerifyRelationship, source, target, owner)  # type: ignore[return-value]

Queries

Traceability queries over a Model.

These answer the questions a requirements-driven workflow actually asks: which requirements are unsatisfied or unverified, what is allocated where, and how do requirements trace to parts.

TraceMatrix dataclass

Requirement-by-part grid of satisfy/verify/allocate marks.

Source code in src/sysml2kit/query.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass
class TraceMatrix:
    """Requirement-by-part grid of satisfy/verify/allocate marks."""

    requirements: list[RequirementUsage] = field(default_factory=list)
    parts: list[PartUsage] = field(default_factory=list)
    #: (requirement_id, part_id) -> set of mark strings ("satisfy", "allocate").
    cells: dict[tuple[str, str], set[str]] = field(default_factory=dict)

    def render(self) -> str:
        """Render as a fixed-width text table."""
        if not self.requirements:
            return "(no requirements)"
        req_labels = [r.declared_short_name or r.label for r in self.requirements]
        part_labels = [p.label for p in self.parts]
        width = max((len(label) for label in req_labels), default=8) + 2
        col = max((len(label) for label in part_labels), default=8) + 2
        header = " " * width + "".join(label.ljust(col) for label in part_labels)
        lines = [header]
        for r, rl in zip(self.requirements, req_labels, strict=True):
            row = rl.ljust(width)
            for p in self.parts:
                marks = self.cells.get((str(r.element_id), str(p.element_id)), set())
                cell = ",".join(sorted(m[0].upper() for m in marks)) if marks else "."
                row += cell.ljust(col)
            lines.append(row)
        lines.append("(S=satisfy, A=allocate; '.'=no link)")
        return "\n".join(lines)

render()

Render as a fixed-width text table.

Source code in src/sysml2kit/query.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def render(self) -> str:
    """Render as a fixed-width text table."""
    if not self.requirements:
        return "(no requirements)"
    req_labels = [r.declared_short_name or r.label for r in self.requirements]
    part_labels = [p.label for p in self.parts]
    width = max((len(label) for label in req_labels), default=8) + 2
    col = max((len(label) for label in part_labels), default=8) + 2
    header = " " * width + "".join(label.ljust(col) for label in part_labels)
    lines = [header]
    for r, rl in zip(self.requirements, req_labels, strict=True):
        row = rl.ljust(width)
        for p in self.parts:
            marks = self.cells.get((str(r.element_id), str(p.element_id)), set())
            cell = ",".join(sorted(m[0].upper() for m in marks)) if marks else "."
            row += cell.ljust(col)
        lines.append(row)
    lines.append("(S=satisfy, A=allocate; '.'=no link)")
    return "\n".join(lines)

allocation_table(model)

Return (allocated element, part) pairs for every allocate relationship.

Source code in src/sysml2kit/query.py
80
81
82
83
84
85
def allocation_table(model: Model) -> list[tuple[Element, Element]]:
    """Return (allocated element, part) pairs for every allocate relationship."""
    return [
        (model.resolve(rel.source), model.resolve(rel.target))
        for rel in model.relationships(kind=AllocateRelationship)
    ]

derived_from(model, requirement)

Return the requirements this one derives from.

Source code in src/sysml2kit/query.py
64
65
66
67
def derived_from(model: Model, requirement: RequirementUsage) -> list[RequirementUsage]:
    """Return the requirements this one derives from."""
    rels = model.relationships(kind=DeriveRelationship, source=requirement)
    return [el for rel in rels if isinstance(el := model.resolve(rel.target), RequirementUsage)]

parts_of(model, scope=None)

Return part usages, optionally only those under a scope element.

Source code in src/sysml2kit/query.py
38
39
40
41
42
43
44
45
46
47
48
49
def parts_of(model: Model, scope: Element | None = None) -> list[PartUsage]:
    """Return part usages, optionally only those under a scope element."""
    if scope is None:
        return list(model.iter_elements(kind=PartUsage))  # type: ignore[arg-type]
    out: list[PartUsage] = []
    stack = list(model.owned_by(scope))
    while stack:
        el = stack.pop()
        if isinstance(el, PartUsage):
            out.append(el)
        stack.extend(model.owned_by(el))
    return out

requirements_in(model, scope=None)

Return requirement usages, optionally only those under a scope element.

Source code in src/sysml2kit/query.py
24
25
26
27
28
29
30
31
32
33
34
35
def requirements_in(model: Model, scope: Element | None = None) -> list[RequirementUsage]:
    """Return requirement usages, optionally only those under a scope element."""
    if scope is None:
        return list(model.iter_elements(kind=RequirementUsage))  # type: ignore[arg-type]
    out: list[RequirementUsage] = []
    stack = list(model.owned_by(scope))
    while stack:
        el = stack.pop()
        if isinstance(el, RequirementUsage):
            out.append(el)
        stack.extend(model.owned_by(el))
    return out

satisfied_by(model, requirement)

Return the elements recorded as satisfying this requirement.

Source code in src/sysml2kit/query.py
52
53
54
55
def satisfied_by(model: Model, requirement: RequirementUsage) -> list[Element]:
    """Return the elements recorded as satisfying this requirement."""
    rels = model.relationships(kind=SatisfyRelationship, target=requirement)
    return [model.resolve(rel.source) for rel in rels]

trace_matrix(model)

Build the requirement-to-part traceability matrix.

Source code in src/sysml2kit/query.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def trace_matrix(model: Model) -> TraceMatrix:
    """Build the requirement-to-part traceability matrix."""
    matrix = TraceMatrix(requirements=requirements_in(model), parts=parts_of(model))
    for rel in model.relationships(kind=SatisfyRelationship):
        source, target = model.resolve(rel.source), model.resolve(rel.target)
        if isinstance(target, RequirementUsage) and isinstance(source, PartUsage):
            matrix.cells.setdefault((str(target.element_id), str(source.element_id)), set()).add(
                "satisfy"
            )
    for rel in model.relationships(kind=AllocateRelationship):
        source, target = model.resolve(rel.source), model.resolve(rel.target)
        if isinstance(source, RequirementUsage) and isinstance(target, PartUsage):
            matrix.cells.setdefault((str(source.element_id), str(target.element_id)), set()).add(
                "allocate"
            )
    return matrix

unsatisfied_requirements(model)

Return requirements with no incoming satisfy relationship.

Source code in src/sysml2kit/query.py
70
71
72
def unsatisfied_requirements(model: Model) -> list[RequirementUsage]:
    """Return requirements with no incoming satisfy relationship."""
    return [r for r in requirements_in(model) if not satisfied_by(model, r)]

unverified_requirements(model)

Return requirements with no incoming verify relationship.

Source code in src/sysml2kit/query.py
75
76
77
def unverified_requirements(model: Model) -> list[RequirementUsage]:
    """Return requirements with no incoming verify relationship."""
    return [r for r in requirements_in(model) if not verified_by(model, r)]

verified_by(model, requirement)

Return the analyses/tests recorded as verifying this requirement.

Source code in src/sysml2kit/query.py
58
59
60
61
def verified_by(model: Model, requirement: RequirementUsage) -> list[Element]:
    """Return the analyses/tests recorded as verifying this requirement."""
    rels = model.relationships(kind=VerifyRelationship, target=requirement)
    return [model.resolve(rel.source) for rel in rels]

Validation

Rule-based model validation.

Rules are registered in a module-level table and identified as S2K0NN. validate(model) runs them all and returns issues sorted by severity.

ValidationIssue dataclass

One finding from one rule against one element.

Source code in src/sysml2kit/validation.py
32
33
34
35
36
37
38
39
@dataclass(frozen=True)
class ValidationIssue:
    """One finding from one rule against one element."""

    rule_id: str
    severity: Severity
    element_id: UUID | None
    message: str

dangling_refs(model)

error: a reference points at an element that is not in the model.

Source code in src/sysml2kit/validation.py
62
63
64
65
66
67
68
@rule("S2K001")
def dangling_refs(model: Model) -> Iterator[ValidationIssue]:
    """error: a reference points at an element that is not in the model."""
    for eid, fieldname, missing in model.check_refs():
        yield ValidationIssue(
            "S2K001", "error", eid, f"field '{fieldname}' references missing element {missing}"
        )

duplicate_short_names(model)

error: two requirements share a declared short name (requirement id).

Source code in src/sysml2kit/validation.py
71
72
73
74
75
76
77
78
79
80
81
82
83
@rule("S2K002")
def duplicate_short_names(model: Model) -> Iterator[ValidationIssue]:
    """error: two requirements share a declared short name (requirement id)."""
    counts = Counter(
        el.declared_short_name
        for el in model.iter_elements(kind=RequirementUsage)
        if el.declared_short_name
    )
    for short, n in counts.items():
        if n > 1:
            yield ValidationIssue(
                "S2K002", "error", None, f"requirement id '{short}' is declared {n} times"
            )

empty_package(model)

info: a package owns nothing.

Source code in src/sysml2kit/validation.py
172
173
174
175
176
177
@rule("S2K008")
def empty_package(model: Model) -> Iterator[ValidationIssue]:
    """info: a package owns nothing."""
    for el in model.iter_elements(kind=Package):
        if not model.owned_by(el):
            yield ValidationIssue("S2K008", "info", el.element_id, f"package '{el.label}' is empty")

fidelity_ladder_shape(model)

error: two bindings on one analysis share a fidelity label; warning: mixed labeling.

Source code in src/sysml2kit/validation.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@rule("S2K010")
def fidelity_ladder_shape(model: Model) -> Iterator[ValidationIssue]:
    """error: two bindings on one analysis share a fidelity label; warning: mixed labeling."""
    per_analysis: dict[UUID, list[str | None]] = {}
    for el in model.iter_elements(kind=MetadataUsage):
        assert isinstance(el, MetadataUsage)
        from sysml2kit.verify.binding import is_binding

        if not is_binding(model, el) or el.annotated is None:
            continue
        label = el.values.get("fidelity")
        per_analysis.setdefault(el.annotated.target, []).append(
            str(label) if label is not None else None
        )
    for analysis_id, labels in per_analysis.items():
        if len(labels) < 2:
            continue
        named = [label for label in labels if label is not None]
        duplicates = {label for label in named if named.count(label) > 1}
        if duplicates:
            yield ValidationIssue(
                "S2K010",
                "error",
                analysis_id,
                f"multiple bindings share fidelity label(s) {sorted(duplicates)}",
            )
        if named and len(named) != len(labels):
            yield ValidationIssue(
                "S2K010",
                "warning",
                analysis_id,
                "some sibling bindings declare a fidelity label and some do not",
            )

opaque_share(model)

info: opaque elements are present (fine, but worth knowing).

Source code in src/sysml2kit/validation.py
180
181
182
183
184
185
186
187
@rule("S2K009")
def opaque_share(model: Model) -> Iterator[ValidationIssue]:
    """info: opaque elements are present (fine, but worth knowing)."""
    opaque = sum(1 for el in model.iter_elements(kind=OpaqueElement))
    if opaque:
        yield ValidationIssue(
            "S2K009", "info", None, f"{opaque} element(s) outside the pragmatic profile"
        )

relationship_endpoint_kinds(model)

error: a traceability relationship points at the wrong element kind.

Source code in src/sysml2kit/validation.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@rule("S2K004")
def relationship_endpoint_kinds(model: Model) -> Iterator[ValidationIssue]:
    """error: a traceability relationship points at the wrong element kind."""
    expectations: list[tuple[type[Relationship], str, bool]] = [
        (SatisfyRelationship, "target", True),
        (VerifyRelationship, "target", True),
        (DeriveRelationship, "source", True),
        (DeriveRelationship, "target", True),
        (AllocateRelationship, "source", False),
    ]
    for kind, end, must_be_requirement in expectations:
        for rel in model.relationships(kind=kind):
            ref = getattr(rel, end)
            if ref.target not in model.elements:
                continue  # S2K001 reports it
            element = model.resolve(ref)
            is_req = isinstance(element, RequirementUsage)
            if must_be_requirement and not is_req:
                yield ValidationIssue(
                    "S2K004",
                    "error",
                    rel.element_id,
                    f"{type(rel).__name__} {end} must be a requirement, "
                    f"got {type(element).__name__} '{element.label}'",
                )

requirement_without_subject(model)

warning: a requirement names no subject and nothing satisfies it.

Source code in src/sysml2kit/validation.py
157
158
159
160
161
162
163
164
165
166
167
168
169
@rule("S2K007")
def requirement_without_subject(model: Model) -> Iterator[ValidationIssue]:
    """warning: a requirement names no subject and nothing satisfies it."""
    for el in model.iter_elements(kind=RequirementUsage):
        assert isinstance(el, RequirementUsage)
        has_satisfier = bool(model.relationships(kind=SatisfyRelationship, target=el))
        if el.subject is None and not has_satisfier:
            yield ValidationIssue(
                "S2K007",
                "warning",
                el.element_id,
                f"requirement '{el.label}' has no subject and no satisfier",
            )

rule(rule_id)

Register a validation rule under an S2K id.

Source code in src/sysml2kit/validation.py
46
47
48
49
50
51
52
53
def rule(rule_id: str) -> Callable[[Rule], Rule]:
    """Register a validation rule under an ``S2K`` id."""

    def register(fn: Rule) -> Rule:
        RULES[rule_id] = fn
        return fn

    return register

sibling_name_clash(model)

error: two named siblings clash, which also breaks stable-id hashing.

Source code in src/sysml2kit/validation.py
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@rule("S2K003")
def sibling_name_clash(model: Model) -> Iterator[ValidationIssue]:
    """error: two named siblings clash, which also breaks stable-id hashing."""
    scopes: list[list[Element]] = [
        [model.elements[r] for r in model.roots],
        *([model.owned_by(eid) for eid in model.owned]),
    ]
    for siblings in scopes:
        counts = Counter(el.declared_name for el in siblings if el.declared_name)
        for name, n in counts.items():
            if n > 1:
                yield ValidationIssue(
                    "S2K003", "error", None, f"sibling name '{name}' is used {n} times in one scope"
                )

unparseable_units(model)

warning: an attribute value carries a unit string pint cannot parse.

Source code in src/sysml2kit/validation.py
143
144
145
146
147
148
149
150
151
152
153
154
@rule("S2K006")
def unparseable_units(model: Model) -> Iterator[ValidationIssue]:
    """warning: an attribute value carries a unit string pint cannot parse."""
    for el in model.iter_elements(kind=AttributeUsage):
        assert isinstance(el, AttributeUsage)
        if el.value is not None and el.value.unit and not is_valid_unit(el.value.unit):
            yield ValidationIssue(
                "S2K006",
                "warning",
                el.element_id,
                f"attribute '{el.label}' has unparseable unit '{el.value.unit}'",
            )

unresolvable_definition(model)

error: a usage's definition ref resolves to nothing.

A subset of S2K001 kept separate because typing errors deserve their own id.

Source code in src/sysml2kit/validation.py
129
130
131
132
133
134
135
136
137
138
139
140
@rule("S2K005")
def unresolvable_definition(model: Model) -> Iterator[ValidationIssue]:
    """error: a usage's definition ref resolves to nothing.

    A subset of S2K001 kept separate because typing errors deserve their own id.
    """
    for el in model.iter_elements():
        definition = getattr(el, "definition", None)
        if definition is not None and definition.target not in model.elements:
            yield ValidationIssue(
                "S2K005", "error", el.element_id, f"'{el.label}' is typed by a missing definition"
            )

validate(model)

Run every rule; issues come back sorted errors-first.

Source code in src/sysml2kit/validation.py
56
57
58
59
def validate(model: Model) -> list[ValidationIssue]:
    """Run every rule; issues come back sorted errors-first."""
    issues = [issue for fn in RULES.values() for issue in fn(model)]
    return sorted(issues, key=lambda i: (_ORDER[i.severity], i.rule_id))

Diff

Element-level model diff.

Matching is by element_id by default; by_name=True falls back to qualified names, for models whose parser regenerated the UUIDs. Semantic (graph-aware) diffing is out of scope for v0.1.

DiffEntry dataclass

One difference between two models.

Source code in src/sysml2kit/diff.py
20
21
22
23
24
25
26
@dataclass(frozen=True)
class DiffEntry:
    """One difference between two models."""

    kind: DiffKind
    qualified_name: str
    detail: str = ""

diff_models(a, b, *, by_name=False)

Compare two models; entries are sorted by qualified name within each kind.

Source code in src/sysml2kit/diff.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def diff_models(a: Model, b: Model, *, by_name: bool = False) -> list[DiffEntry]:
    """Compare two models; entries are sorted by qualified name within each kind."""
    keys_a = _key_map(a, by_name)
    keys_b = _key_map(b, by_name)

    added = [DiffEntry("added", b.qualified_name(keys_b[k])) for k in keys_b.keys() - keys_a]
    removed = [DiffEntry("removed", a.qualified_name(keys_a[k])) for k in keys_a.keys() - keys_b]

    changed: list[DiffEntry] = []
    moved: list[DiffEntry] = []
    for key in keys_a.keys() & keys_b.keys():
        ea, eb = a.elements[keys_a[key]], b.elements[keys_b[key]]
        name = a.qualified_name(ea)
        if type(ea) is not type(eb):
            changed.append(
                DiffEntry("changed", name, f"kind {type(ea).__name__} -> {type(eb).__name__}")
            )
            continue
        fields = [
            f
            for f in type(ea).model_fields
            if f != "element_id" and getattr(ea, f) != getattr(eb, f)
        ]
        # With name matching, refs differ whenever ids were regenerated; compare
        # them by the qualified name of what they point at instead.
        if by_name:
            fields = [
                f
                for f in fields
                if not (
                    isinstance(getattr(ea, f), Ref)
                    and _field_repr(ea, f, a) == _field_repr(eb, f, b)
                )
            ]
        if fields:
            details = ", ".join(
                f"{f}: {_field_repr(ea, f, a)} -> {_field_repr(eb, f, b)}" for f in fields
            )
            changed.append(DiffEntry("changed", name, details))
        owner_a = a.owner.get(ea.element_id)
        owner_b = b.owner.get(eb.element_id)
        owner_a_name = a.qualified_name(owner_a) if owner_a else None
        owner_b_name = b.qualified_name(owner_b) if owner_b else None
        if owner_a_name != owner_b_name:
            moved.append(DiffEntry("moved", name, f"{owner_a_name} -> {owner_b_name}"))

    out: list[DiffEntry] = []
    for group in (added, removed, changed, moved):
        out.extend(sorted(group, key=lambda e: e.qualified_name))
    return out

render_diff(entries)

Render diff entries one per line, with +/-/~/> markers.

Source code in src/sysml2kit/diff.py
 97
 98
 99
100
101
102
103
104
105
106
def render_diff(entries: list[DiffEntry]) -> str:
    """Render diff entries one per line, with +/-/~/> markers."""
    if not entries:
        return "(models are identical)"
    marker = {"added": "+", "removed": "-", "changed": "~", "moved": ">"}
    lines = []
    for e in entries:
        suffix = f"  ({e.detail})" if e.detail else ""
        lines.append(f"{marker[e.kind]} {e.qualified_name}{suffix}")
    return "\n".join(lines)

Interop

Tool-agnostic requirement extraction: the metricKey convention.

A requirement usage that owns attributes named metricKey, threshold, op (one of >= <= == > <), and optionally severity is machine- checkable. extract_requirements turns each into a :class:RequirementSpec with the threshold in both operator form (op + value) and bound form (minimum/maximum), so downstream tools of either dialect consume it with a small adapter: an op-form requirements engine maps op/value straight through, a bound-form one takes minimum/maximum. Adapter code lives in the consuming packages, not here.

RequirementSpec

Bases: BaseModel

One machine-checkable requirement, extracted from a model.

Source code in src/sysml2kit/interop/requirements.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class RequirementSpec(BaseModel):
    """One machine-checkable requirement, extracted from a model."""

    id: str
    name: str
    metric_key: str
    op: Op | None = None
    value: float | None = None
    minimum: float | None = None
    maximum: float | None = None
    units: str | None = None
    severity: Severity = "must"
    source_element_id: str
    satisfied_by: list[str] = []
    verified_by: list[str] = []

extract_requirements(model)

Extract every requirement that follows the metricKey convention.

Source code in src/sysml2kit/interop/requirements.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def extract_requirements(model: Model) -> list[RequirementSpec]:
    """Extract every requirement that follows the metricKey convention."""
    specs: list[RequirementSpec] = []
    for req in model.iter_elements(kind=RequirementUsage):
        assert isinstance(req, RequirementUsage)
        metric = _owned_attribute(model, req, "metricKey")
        if metric is None or metric.value is None or not isinstance(metric.value.value, str):
            continue
        threshold = _owned_attribute(model, req, "threshold")
        op_attr = _owned_attribute(model, req, "op")
        severity_attr = _owned_attribute(model, req, "severity")

        op: Op | None = None
        value: float | None = None
        units: str | None = None
        minimum: float | None = None
        maximum: float | None = None
        if (
            threshold is not None
            and threshold.value is not None
            and isinstance(threshold.value.value, int | float)
            and op_attr is not None
            and op_attr.value is not None
            and op_attr.value.value in (">=", "<=", "==", ">", "<")
        ):
            op = op_attr.value.value  # type: ignore[assignment]
            value = float(threshold.value.value)
            units = threshold.value.unit
            assert op is not None
            minimum, maximum = _bounds(op, value)

        severity: Severity = "must"
        if severity_attr is not None and severity_attr.value is not None:
            raw = severity_attr.value.value
            if raw in ("must", "should", "nice"):
                severity = raw  # type: ignore[assignment]

        specs.append(
            RequirementSpec(
                id=req.declared_short_name or str(req.element_id),
                name=req.declared_name or "",
                metric_key=metric.value.value,
                op=op,
                value=value,
                minimum=minimum,
                maximum=maximum,
                units=units,
                severity=severity,
                source_element_id=str(req.element_id),
                satisfied_by=[model.qualified_name(el) for el in satisfied_by(model, req)],
                verified_by=[model.qualified_name(el) for el in verified_by(model, req)],
            )
        )
    return specs

Units

Unit-string helpers backed by pint.

Models store units as text (round-trip fidelity with the textual notation); these helpers check and convert them. A few decibel spellings common in engineering practice are registered on top of pint's defaults.

check_dimensionality(unit_a, unit_b)

Return whether two unit texts share a dimensionality.

Source code in src/sysml2kit/units.py
58
59
60
def check_dimensionality(unit_a: str, unit_b: str) -> bool:
    """Return whether two unit texts share a dimensionality."""
    return bool(parse_unit(unit_a).dimensionality == parse_unit(unit_b).dimensionality)

convert(value, from_unit, to_unit)

Convert a value between two unit texts.

Source code in src/sysml2kit/units.py
52
53
54
55
def convert(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a value between two unit texts."""
    quantity = registry().Quantity(value, parse_unit(from_unit))
    return float(quantity.to(parse_unit(to_unit)).magnitude)

is_valid_unit(text)

Return whether pint can parse this unit text.

Source code in src/sysml2kit/units.py
43
44
45
46
47
48
49
def is_valid_unit(text: str) -> bool:
    """Return whether pint can parse this unit text."""
    try:
        parse_unit(text)
    except ValueError:
        return False
    return True

parse_unit(text)

Parse unit text into a pint unit; raises ValueError on unknown units.

Source code in src/sysml2kit/units.py
35
36
37
38
39
40
def parse_unit(text: str) -> Any:
    """Parse unit text into a pint unit; raises ValueError on unknown units."""
    try:
        return registry().Unit(text)
    except Exception as exc:
        raise ValueError(f"unparseable unit {text!r}: {exc}") from exc

registry() cached

Return the shared pint unit registry (created on first use).

Source code in src/sysml2kit/units.py
26
27
28
29
30
31
32
@lru_cache(maxsize=1)
def registry() -> Any:
    """Return the shared pint unit registry (created on first use)."""
    reg: Any = pint.UnitRegistry()
    for definition in _EXTRA_DEFINITIONS:
        reg.define(definition)
    return reg