Compounds#

The Collection class is a powerful tool for grouping and tracking object assemblies. However, it is often convenient to have assembly parameters themselves, like number of magnets, as variables. This is achieved by sub-classing Collection. We refer to such classes as “Compounds” and show how to seamlessly integrate them into Magpylib.

Subclassing collections#

In the following example we design a compound class MagnetRing which represents a ring of cuboid magnets with the parameter cubes that should refer to the number of magnets on the ring. The ring will automatically adjust its size when cubes is modified. We also add an encompassing 3D model.

import magpylib as magpy

class MagnetRing(magpy.Collection):
    """ A ring of cuboid magnets

    Parameters
    ----------
    cubes: int, default=6
        Number of cubes on ring.
    """

    def __init__(self, cubes=6, **kwargs):
        super().__init__(**kwargs)             # hand over style args
        self._update(cubes)

    @property
    def cubes(self):
        """Number of cubes"""
        return self._cubes

    @cubes.setter
    def cubes(self, inp):
        """Set cubes"""
        self._update(inp)

    def _update(self, cubes):
        """Update MagnetRing instance"""
        self._cubes = cubes
        ring_radius = cubes/300

        # Store existing path
        pos_temp = self.position
        ori_temp = self.orientation

        # Clean up old object properties
        self.reset_path()
        self.children = []
        self.style.model3d.data.clear()

        # Add children
        for i in range(cubes):
            child = magpy.magnet.Cuboid(
                polarization=(0,0,1),
                dimension=(.01,.01,.01),
                position=(ring_radius,0,0)
            )
            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
            self.add(child)

        # Re-apply path
        self.position = pos_temp
        self.orientation = ori_temp

        # Add parameter-dependent 3d trace
        trace = magpy.graphics.model3d.make_CylinderSegment(
            dimension=(ring_radius-.006, ring_radius+.006, 0.011, 0, 360),
            vert=150,
            opacity=0.2,
        )
        self.style.model3d.add_trace(trace)

        return self

This new MagnetRing class seamlessly integrates into Magpylib and makes use of the position and orientation interface, field computation and graphic display.

# Add a sensor
sensor = magpy.Sensor(position=(0, 0, 0))

# Create a MagnetRing object
ring = MagnetRing()

# Move MagnetRing around
ring.rotate_from_angax(angle=45, axis='x')

# Compute field
print(f"B-field at sensor → {ring.getB(sensor).round(2)}")

# Display graphically
magpy.show(ring, sensor, backend='plotly')
B-field at sensor → [ 0.    0.04 -0.04]

The MagnetRing parameter cubes can be modified dynamically:

print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(3)}")

ring.cubes = 10

print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(3)}")

magpy.show(ring, sensor, backend='plotly')
B-field at sensor for modified ring → [ 0.     0.042 -0.042]
B-field at sensor for modified ring → [-0.     0.015 -0.015]

Postponed trace construction#

In the above example, the trace is constructed in _update, every time the parameter cubes is modified. This can lead to an unwanted computational overhead, especially as the construction is only necessary for graphical representation.

To make our compounds ready for heavy computation, while retaining Magpylib graphic possibilities, it is possible to provide a trace which will only be constructed when show is called. The following modification of the above example demonstrates this:

class MagnetRingAdv(magpy.Collection):
    """ A ring of cuboid magnets

    Parameters
    ----------
    cubes: int, default=6
        Number of cubes on ring.
    """

    def __init__(self, cubes=6, **style_kwargs):
        super().__init__(**style_kwargs)             # hand over style args
        self._update(cubes)

        # Hand trace over as callable
        self.style.model3d.add_trace(self._custom_trace3d)

    @property
    def cubes(self):
        """Number of cubes"""
        return self._cubes

    @cubes.setter
    def cubes(self, inp):
        """Set cubes"""
        self._update(inp)

    def _update(self, cubes):
        """Update MagnetRing instance"""
        self._cubes = cubes
        ring_radius = cubes/300

        # Store existing path and reset
        pos_temp = self.position
        ori_temp = self.orientation
        self.reset_path()

        # Add children
        for i in range(cubes):
            child = magpy.magnet.Cuboid(
                polarization=(0,0,1),
                dimension=(.01,.01,.01),
                position=(ring_radius,0,0)
            )
            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
            self.add(child)

        # Re-apply path
        self.position = pos_temp
        self.orientation = ori_temp

        return self

    def _custom_trace3d(self):
        """ creates a parameter-dependent 3d model"""
        trace = magpy.graphics.model3d.make_CylinderSegment(
            dimension=(self.cubes/300-.006, self.cubes/300+0.006, 0.011, 0, 360),
            vert=150,
            opacity=0.2,
        )
        return trace

We have removed the trace construction from the _update method, and instead provided _custom_trace3d as a callable.

ring0 = MagnetRing()
%time for _ in range(10): ring0.cubes=10

ring1 = MagnetRingAdv()
%time for _ in range(10): ring1.cubes=10
CPU times: user 119 ms, sys: 0 ns, total: 119 ms
Wall time: 119 ms
CPU times: user 457 ms, sys: 250 µs, total: 458 ms
Wall time: 467 ms

This example is not very impressive because the provided trace is not very heavy.