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:

  1. It finds the typed part-whole relationship field of whole whose element type matches type(part) and stores the part there (a single-valued field like handle, or appends to a list field like drawers).

  2. 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