Skip to content

Magnetic levitation under alternating impulses

A conductive ball levitated in a vacuum by a field that pushes and pulls it with alternating impulses.

  • Name: magnetic_levitation
  • Field: engineering/mechatronics
  • Formalism: ordinary differential equations
  • References: Pilipchuk 2010; Trzaska 2023

Select this system

from skcomplex.systems import create_simulation, create_system, describe

system = create_system('magnetic_levitation')
entry = describe('magnetic_levitation')
trajectory = create_simulation('magnetic_levitation').prepare().run().trajectory_

Factory

@register_system(
    field='engineering/mechatronics',
    tags=('mechatronics', 'levitation', 'impulses', 'events', 'ode'),
    references=('Pilipchuk 2010', 'Trzaska 2023'),
    defaults={'until': 20.0, 'dt': 0.01},
)
def magnetic_levitation(v: float | None = None, *, b: float = 1.0, m: float = 0.2) -> System:
    """Return a conductive ball levitated in a vacuum by a field that pushes and pulls it with alternating impulses.

    The velocity follows `v' + b v = m Σ_k [δ(t + 1 - 4 k) - δ(t - 1 - 4 k)]`: it relaxes freely, rises by `m` at the
    times `4 k - 1`, and falls by `m` at the times `4 k + 1`. Its periodic solution, with the triangular wave
    `τ(t) = (2 / π) arcsin(sin(π t / 2))` and the rectangular wave `e(t) = sign(cos(π t / 2))`, is
    `v = m / (2 cosh b) exp(-b τ(t) e(t)) e(t)`, which the ball follows from the start when it starts at
    `m / (2 cosh b)`.

    Args:
        v:
            The initial velocity, or `None` for the value `m / (2 cosh b)` of the periodic solution at time 0.

        b:
            The damping rate.

        m:
            The strength of every impulse.

    Returns:
        system:
            The system, with the velocity `ball.v`, raised by the impulses `push` and lowered by the impulses `pull`.

    Examples:
        >>> from skcomplex.systems.engineering.mechatronics import magnetic_levitation
        >>> round(magnetic_levitation(b=1.0, m=0.2).elements[0].v, 4)
        0.0648
    """
    start = m / (2 * math.cosh(b)) if v is None else v
    push = {'v': lambda s: s.target.v + s.target.m}
    pull = {'v': lambda s: s.target.v - s.target.m}
    return System(
        elements=[LevitatedBallElement('ball', v=start, b=b, m=m)],
        interactions=[
            ImpulseInteraction('push', target='ball', reset=push, period=IMPULSE_PERIOD, start=3.0),
            ImpulseInteraction('pull', target='ball', reset=pull, period=IMPULSE_PERIOD, start=1.0),
        ],
    )

Components

Elements:

  • LevitatedBallElement, from Element

Interactions:

  • ImpulseInteraction
class LevitatedBallElement(Element):
    """Conductive ball levitated in a vacuum, whose velocity relaxes between the impulses of the levitating field.

    Between impulses the velocity follows `v' = -b v`, and every impulse of the field changes it by `m` or `-m`.

    Args:
        label:
            The label of the ball.

        visual:
            How the ball is drawn, or `None` for `default_visual_`.

        v:
            The initial velocity.

        b:
            The damping rate.

        m:
            The strength of every impulse.

    Examples:
        >>> from skcomplex.systems.engineering.mechatronics import LevitatedBallElement
        >>> LevitatedBallElement('ball').m
        0.2
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        v: Annotated[float, StateSpec()] = 0.0,
        b: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
        m: Annotated[float, ParameterSpec()] = 0.2,
    ) -> None:
        super().__init__(label, visual=visual)
        self.v = v
        self.b = b
        self.m = m

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the relaxation of the velocity.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the component.
        """
        return [RateLaw(s.v, -s.b * s.v)]

class ImpulseInteraction(Interaction):
    """Impulses that set states of the target to new values at given times, or at periodic times.

    The impulses fall at every time of `times` and, with a `period`, at `start + j * period` for `j` from 0, `count`
    times or without end. An impulse at the initial time of a run does not fire. The new values are functions of the
    namespace, which holds `namespace.target`, the states and parameters of the target, and the time `namespace.t`. An
    impulse sets the states of every member of a group at once, and the trajectory records every impulse among its
    events, under the label of the interaction.

    Args:
        label:
            The label of the interaction, unique among the parts of its system.

        target:
            The path of the element or group whose states the impulses set.

        reset:
            A mapping from the name of a state of the target to the function `f(namespace)` of its new value.

        times:
            The times of the impulses.

        period:
            The period of the periodic impulses, or `None` for none.

        start:
            The time of the first periodic impulse.

        count:
            The number of periodic impulses, or `None` for impulses without end.

    Raises:
        ValidationError:
            The impulses set no state, a new value is not a function, the impulses have no time, the period is not
            positive, or the count is not a positive integer.

    Examples:
        >>> from skcomplex.core.components.interactions import ImpulseInteraction
        >>> reset = {'c': lambda s: s.target.c + 1.0}
        >>> dose = ImpulseInteraction('dose', target='body', reset=reset, period=8.0, count=3)
        >>> dose.start
        0.0
    """

    law_classes: ClassVar[tuple[type[BaseLaw], ...]] = (CrossingLaw,)

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec(elements=(ElementKind.ANY,), states=('reset',))],
        reset: Mapping[str, Callable[[SimpleNamespace], object]],
        times: Sequence[float] = (),
        period: float | None = None,
        start: float = 0.0,
        count: int | None = None,
    ) -> None:
        super().__init__(label)
        self.target = target
        self.reset = reset
        self.times = times
        self.period = period
        self.start = start
        self.count = count

    def _check(self) -> None:
        """Raise an error for impulses without states or times, or with a period or a count they cannot take.

        Raises:
            ValidationError:
                The impulses set no state, a new value is not a function, the impulses have no time, the period is not
                positive, or the count is not a positive integer.
        """
        super()._check()
        if not self.reset or not all(callable(function) for function in self.reset.values()):
            error_msg = (
                f'The impulses {self.label!r} set {self.reset!r}. Map state names to functions of the namespace.'
            )
            raise ValidationError(error_msg)
        if not self.times and self.period is None:
            error_msg = f'The impulses {self.label!r} have no time. Give `times`, or a `period`.'
            raise ValidationError(error_msg)
        if self.period is not None and not self.period > 0:
            error_msg = f'The period of the impulses {self.label!r} is {self.period!r}. Give a positive number.'
            raise ValidationError(error_msg)
        if self.count is not None and (
            isinstance(self.count, bool) or not isinstance(self.count, int) or self.count < 1
        ):
            error_msg = f'The count of the impulses {self.label!r} is {self.count!r}. Give a positive integer, or None.'
            raise ValidationError(error_msg)

    def _get_periodic_guard(self, time: Expression, period: float) -> Expression:
        """Return a guard that crosses zero at every periodic impulse and stays away from zero outside them."""
        guard = where(time < self.start - period / 2, -1, sin(pi * (time - self.start) / period))
        if self.count is None:
            return guard
        last = self.count - SR(1) / 2
        return where(time > self.start + last * period, sin(pi * last), guard)

    def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
        """Return the crossing laws of time at the impulses.

        Args:
            namespace:
                The namespace of the symbols the laws can use, with `namespace.target` and the time `namespace.t`.

        Returns:
            laws:
                One crossing law per given time and one for the periodic impulses.
        """
        reset = {
            getattr(self._get_member(namespace, 'target'), name): SR(f(namespace)) for name, f in self.reset.items()
        }
        laws: list[BaseLaw] = [CrossingLaw(namespace.t - time, reset, 1, label=self.label) for time in self.times]
        if self.period is not None:
            laws.append(CrossingLaw(self._get_periodic_guard(namespace.t, self.period), reset, 0, label=self.label))
        return laws

Equations

ball.v: diff(v_ball(t), t) == -b_ball*v_ball(t)