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 a slider. We use the factories (create_with_new_body_in_world) to quickly spawn each part with geometry, then wire them together with add.

from semantic_digital_twin.spatial_types.spatial_types import HomogeneousTransformationMatrix, Vector3
from semantic_digital_twin.semantic_annotations.semantic_annotations import Drawer, Handle, Slider, 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(),
    )
    handle = Handle.create_with_new_body_in_world(
        name="drawer_handle",
        world_root_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=-0.15),
        world=world,
    )
    slider = Slider.create_with_new_body_in_world(
        name="drawer_slider",
        world_root_T_self=HomogeneousTransformationMatrix(),
        world=world,
        parent_connection_specification=Slider.parent_connection_specification(
            axis=Vector3.X()
        ),
    )

    # One method, routed by type: handle -> drawer.handle, slider -> drawer.mechanical_joint
    drawer.add(handle)
    drawer.add(slider)
    # 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.mechanical_joint is slider
assert drawer in dresser.drawers
print("drawer.handle:", drawer.handle)
print("drawer.mechanical_joint:", drawer.mechanical_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('a2085ed9-2a85-4fb9-9d7d-1f180809c157'), root=Body(name=PrefixedName('None/drawer_handle'), id=UUID('c6e2ff09-b145-4fe7-aa40-042138d23c2d'), index=3))
drawer.mechanical_joint: Slider(name=PrefixedName('None/drawer_slider'), id=UUID('1fa6987e-7db3-4d10-80f3-bcc748e1aecf'), root=Body(name=PrefixedName('None/drawer_slider'), id=UUID('705e8caa-8ce0-409e-b117-1427c5e3f888'), index=4))
dresser.drawers: [Drawer(name=PrefixedName('None/drawer'), id=UUID('fda9b627-ba33-4bea-9637-f5ceeaa1ee0c'), root=Body(name=PrefixedName('None/drawer'), id=UUID('c601350d-5180-4307-b142-e0c016bedd74'), index=2), mechanical_joint=Slider(name=PrefixedName('None/drawer_slider'), id=UUID('1fa6987e-7db3-4d10-80f3-bcc748e1aecf'), root=Body(name=PrefixedName('None/drawer_slider'), id=UUID('705e8caa-8ce0-409e-b117-1427c5e3f888'), index=4)), handle=Handle(name=PrefixedName('None/drawer_handle'), id=UUID('a2085ed9-2a85-4fb9-9d7d-1f180809c157'), root=Body(name=PrefixedName('None/drawer_handle'), id=UUID('c6e2ff09-b145-4fe7-aa40-042138d23c2d'), index=3)), 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, HasMechanicalJoint, 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),
        world=world,
        scale=Scale(0.05, 0.1, 0.02),
    )
    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