Part-Whole Relationships#
Many semantic annotations are made of parts: a drawer has a handle and a slider, a dresser has
drawers and doors. This structural part-of relation is the part-whole relationship, and the
semantic digital twin models it with the single, type-routed method
semantic_digital_twin.semantic_annotations.mixins.PartWholeRelationship.add().
Calling whole.add(part) does two things:
It finds the typed part-whole relationship field of
wholewhose element type matchestype(part)and stores the part there (a single-valued field likehandle, or appends to a list field likedrawers).It lets the part mount itself into the kinematic structure (by default it becomes a kinematic child of the whole), so moving the whole moves the part with it.
A part-whole relationship is parthood, not occupancy: a handle is part of a drawer. The cup
standing on a table is not part of the table — that “located-in/on” relation handled
separately by semantic_digital_twin.semantic_annotations.mixins.IsStorageSpace and its
add_object method.
Used Concepts:
Composing built-in annotations with add#
Let’s build a dresser whose drawer has a handle and slides on rails. We use the factories
(create_with_new_body_in_world) to quickly spawn each part with geometry, then wire them
together with add. The rails are no part of their own: they are the prismatic connection the
drawer hangs from, which drawer.movable_joint returns.
import numpy as np
from krrood_spatial_math.spatial_types import HomogeneousTransformationMatrix, Vector3
from semantic_digital_twin.specifications.connections import (
PrismaticConnectionSpecification,
)
from semantic_digital_twin.semantic_annotations.semantic_annotations import Drawer, Handle, Dresser
from semantic_digital_twin.spatial_computations.raytracer import RayTracer
from semantic_digital_twin.world_description.geometry import Scale
from semantic_digital_twin.world import World
world = World.create_with_root_body()
with world.modify_world():
dresser = Dresser.create_with_new_body_in_world(
name="dresser",
scale=Scale(0.31, 0.31, 0.21),
world=world,
world_root_T_self=HomogeneousTransformationMatrix(),
)
drawer = Drawer.create_with_new_body_in_world(
name="drawer",
scale=Scale(0.3, 0.3, 0.2),
world=world,
world_root_T_self=HomogeneousTransformationMatrix(),
parent_connection_specification=PrismaticConnectionSpecification(
axis=Vector3.X()
),
)
handle = Handle.create_with_new_body_in_world(
name="drawer_handle",
world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=-0.15),
world=world,
)
# One method, routed by type: handle -> drawer.handle
drawer.add(handle)
# drawer -> dresser.drawers (a list field, so it is appended)
dresser.add(drawer)
# add routed each part to the field whose element type it matches.
assert drawer.handle is handle
assert drawer in dresser.drawers
# Mounting the drawer kept the rails it slides on, now anchored at the dresser.
assert drawer.movable_joint.parent is dresser.root
print("drawer.handle:", drawer.handle)
print("drawer.movable_joint:", drawer.movable_joint)
print("dresser.drawers:", dresser.drawers)
rt = RayTracer(world)
rt.update_scene()
rt.scene.show("jupyter")
drawer.handle: Handle(name=PrefixedName('None/drawer_handle'), id=UUID('163d0fbf-ba28-48d0-b560-197d4fec836f'), root=Body(name=PrefixedName('None/drawer_handle'), id=UUID('b10ce38e-a771-4791-8f57-9bf561e79330'), index=3), grasp_candidate_count=12)
drawer.movable_joint: PrismaticConnection(name=PrefixedName('None/dresser_T_drawer'), id=UUID('ef385c48-4ce0-53bb-a50a-e6cfd41b0dc1'), parent=Body(name=PrefixedName('None/dresser'), id=UUID('ea43f6f3-136b-464d-b941-c6753afcbad3'), index=1), child=Body(name=PrefixedName('None/drawer'), id=UUID('c57822b1-fd10-43cc-ac0f-b8ced3bfa67d'), index=2), parent_T_connection_expression=HomogeneousTransformationMatrix(@1=1, @2=0,
[[@1, @2, @2, @2],
[@2, @1, @2, @2],
[@2, @2, @1, @2],
[@2, @2, @2, @1]]), _kinematics=HomogeneousTransformationMatrix(@1=1, @2=0,
[[@1, @2, @2, map_T_drawer/dof/position],
[@2, @1, @2, @2],
[@2, @2, @1, @2],
[@2, @2, @2, @1]]), connection_T_child_expression=HomogeneousTransformationMatrix(@1=1,
[[@1, 00, 00, 00],
[00, @1, 00, 00],
[00, 00, @1, 00],
[00, 00, 00, @1]]), axis=Vector3(@1=0, [1, @1, @1, @1]), multiplier=1.0, offset=0.0, raw_dof=DegreeOfFreedom(name=PrefixedName('map_T_drawer/dof'), id=UUID('6470a414-02ae-4c48-86d6-611f4deef37e'), limits=DegreeOfFreedomLimits(lower=DerivativeMap(position=None, velocity=None, acceleration=None, jerk=None), upper=DerivativeMap(position=None, velocity=None, acceleration=None, jerk=None)), variables=DerivativeMap(position=PositionVariable(name='map_T_drawer/dof/position', id=UUID('f5b1d310-7a8d-4368-a2f1-95d76eda43aa'), resolve=<bound method DegreeOfFreedomVariable.resolve of ...>, dof=...), velocity=VelocityVariable(name='map_T_drawer/dof/velocity', id=UUID('66a6f553-b4d4-4826-b9ad-b731fbff6cb7'), resolve=<bound method DegreeOfFreedomVariable.resolve of ...>, dof=...), acceleration=AccelerationVariable(name='map_T_drawer/dof/acceleration', id=UUID('398b2afe-b3e4-4ede-8204-152fef27c447'), resolve=<bound method DegreeOfFreedomVariable.resolve of ...>, dof=...), jerk=JerkVariable(name='map_T_drawer/dof/jerk', id=UUID('9975d090-cbd9-4d26-947d-8bb1a8a8eb4d'), resolve=<bound method DegreeOfFreedomVariable.resolve of ...>, dof=...)), has_hardware_interface=False), dynamics=JointDynamics(armature=0.0, dry_friction=0.0, damping=0.0))
dresser.drawers: [Drawer(name=PrefixedName('None/drawer'), id=UUID('e4a81d48-3053-4749-95ec-4e22aceab053'), root=Body(name=PrefixedName('None/drawer'), id=UUID('c57822b1-fd10-43cc-ac0f-b8ced3bfa67d'), index=2), grasp_candidate_count=12, handle=Handle(name=PrefixedName('None/drawer_handle'), id=UUID('163d0fbf-ba28-48d0-b560-197d4fec836f'), root=Body(name=PrefixedName('None/drawer_handle'), id=UUID('b10ce38e-a771-4791-8f57-9bf561e79330'), index=3), grasp_candidate_count=12), objects=[], supporting_surface=None)]
add raised nothing and put each part in the right place, because every part type matched
exactly one part-whole relationship field of its target.
Part-whole relationship fields for your own annotations#
Part kinds the library already models come with ready-made mixins — HasHandle, HasDrawers,
HasDoors, HasApertures, HasLegs, HasSink. Inheriting the mixin
gives your annotation the field, its metadata, and the add routing for free; do not re-declare
such a field yourself. Only a part kind that no mixin covers needs its own field, declared with
field(metadata=IsPartWholeRelationship().as_dict()). That marker in the field’s metadata — not
where the field sits in the class hierarchy — is what makes it a part-whole relationship field,
so a plain field(...) on the same class is simply not one and is ignored by add.
The ControlPanel below combines both: its handle field comes from HasHandle (the same mixin
Drawer inherits it from), and only the emergency button — a part kind no mixin models — gets a
field of its own.
from dataclasses import dataclass, field
from typing import Optional
from semantic_digital_twin.semantic_annotations.mixins import HasHandle, HasRootBody
from semantic_digital_twin.semantic_annotations.part_whole import IsPartWholeRelationship
@dataclass(eq=False)
class EmergencyButton(HasRootBody):
"""A part kind none of the built-in mixins cover."""
@dataclass(eq=False)
class ControlPanel(HasHandle):
"""A custom annotation with a handle and an emergency button as structural parts."""
emergency_button: Optional[EmergencyButton] = field(
default=None,
metadata=IsPartWholeRelationship().as_dict(),
)
"""A part-whole relationship field: parts of type ``EmergencyButton`` are routed here by ``add``."""
label: Optional[str] = field(default=None)
"""A plain field — *not* a part-whole relationship field, so ``add`` never touches it."""
Now we can build a ControlPanel and add its parts exactly like the built-in annotations —
add routes the handle into the inherited mixin field and the button into the declared one:
with world.modify_world():
panel = ControlPanel.create_with_new_body_in_world(
name="panel",
scale=Scale(0.3, 0.2, 0.02),
world=world,
world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(z=0.5),
)
panel_handle = Handle.create_with_new_body_in_world(
name="panel_handle",
world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(
x=0.1, z=0.5, roll=np.pi / 2
),
world=world,
scale=Scale(0.05, 0.02, 0.1),
)
panel_button = EmergencyButton.create_with_new_body_in_world(
name="panel_button",
world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=-0.1, z=0.5),
world=world,
scale=Scale(0.03, 0.03, 0.02),
)
panel.add(panel_handle)
panel.add(panel_button)
assert panel.handle is panel_handle
assert panel.emergency_button is panel_button
print("panel.handle is panel_handle:", panel.handle is panel_handle)
print("panel.emergency_button is panel_button:", panel.emergency_button is panel_button)
panel.handle is panel_handle: True
panel.emergency_button is panel_button: True
If you try to add a part whose type matches none of the part-whole relationship fields, add
refuses with
semantic_digital_twin.exceptions.CannotBeAPartOf — a ControlPanel has nowhere to put
a Drawer:
from semantic_digital_twin.exceptions import CannotBeAPartOf
with world.modify_world():
stray_drawer = Drawer.create_with_new_body_in_world(
name="stray_drawer",
scale=Scale(0.2, 0.2, 0.2),
world=world,
world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(y=0.6),
)
rejected = False
try:
panel.add(stray_drawer)
except CannotBeAPartOf as error:
rejected = True
print("Rejected as expected:", error)
assert rejected
Rejected as expected: Drawer cannot be added as a part of ControlPanel: no part-whole relationship field accepts it.
Suggestion: Check out the superclasses of ControlPanel, to find the currently valid part-whole relationships, and if you think its currently incomplete, feel free to adjust it and make a PR for it
If you think you have understood everything in this tutorial, you may try out our self-assessment quiz for this user guide