Source code for gigablochs.animation

import numpy as np
import scipy.signal as sig
from sympy.ntheory import divisors

from gigablochs import SHELL
if 'notebook' in SHELL:
    from IPython.display import Video, display

def round_divisor(numerator, denominator):
    """
    Round the ratio `numerator / denominator` to an integer by
    adjusting the denominator to the closest factor of the numerator.

    Parameters
    ----------
    numerator : int
        Numerator of the ratio, must be an integer.
    denominator : scalar
        Ratio denominator to be adjusted.

    Returns
    -------
    devisor : int
        The adjusted denominator, now an integer that divides numerator.

    """
    factors = np.array(divisors(numerator))
    return factors[abs(denominator - factors).argmin()]

def downsample(time_signal, new_time_increment, duration='~20', mode='filter', **kwargs):
    length = time_signal.shape[0]
    if approx := isinstance(duration, str) and duration.startswith('~'):
        duration = float(duration[1:])
    factor = length * new_time_increment / duration
    if approx:
        if factor >= 1:
            factor = round_divisor(length, factor)
        else:
            factor = 1 / round(1 / factor)
        duration = length * new_time_increment / factor
    # else use exact duration provided, must result in integer reduction factor and new shape
    time_steps = np.arange(0, duration, new_time_increment) # TODO: debug off-by-one error occasionally when upsampling
    if time_steps.size != (new_shape := length / factor) or (mode != 'fourier' and factor >= 1 and not factor.is_integer()):
        message = f'Desired {duration=}, {new_time_increment=} are incompatible with {length=} and result in downsampling {factor=} and {new_shape=}, please adjust to ensure integers'
        raise ValueError(message)
    if mode == 'fourier':
        kwargs.setdefault('window', 'rect')
        resampled = sig.resample(time_signal, length / factor, axis=0, **kwargs)
    elif mode == 'filter':
        up = 1 if factor >= 1 else round(1 / factor)
        down = factor if factor >= 1 else 1
        kwargs.setdefault('padtype', 'line')
        resampled = sig.resample_poly(time_signal, up, down, axis=0, **kwargs)
    elif mode == 'alias':
        if factor < 1:
            message = f'Aliasing mode is only possible for downsampling, got {factor=}'
            raise ValueError(message)
        if kwargs:
            message = f'got unexpected keyword arguments {kwargs=}'
            raise ValueError(message)
        resampled = time_signal[::factor]
    else:
        message = f'Unsupported {mode=}, must be in {{"fourier", "filter", "alias"}}'
        raise ValueError(message)
    return time_steps, resampled

def constant_speed(og_time_steps, new_time_steps):
    return (og_time_steps[-1] - og_time_steps[0]) / (new_time_steps[-1] - new_time_steps[0])

def rescale_Beff(Beff, arrow_length=1):
    Beff = Beff * 1e6 # µT
    Bmax = np.linalg.norm(Beff, axis=-1).max()
    return arrow_length * Beff / Bmax, Bmax

[docs] def bloch_sphere(magnetization, B_field=None, time_increments=0.1, speed=None, traces=('magnetization', 'B_field_projection'), engine='manim-cairo', prologue=True, preview=True, quality='low_quality', progress_bar='display', max_files_cached=1000, max_width=85, **kwargs): """ Animate magnetization and B-field vectors on a Bloch sphere. This function creates a 3D animation of magnetization evolving on the Bloch sphere, optionally showing the magnetic field and various traces. Parameters ---------- magnetization : array_like Magnetization vectors to animate. Should be an array of shape (N, 3) where N is the number of time points and the last dimension contains the (x, y, z) components. B_field : array_like, optional Magnetic field vectors corresponding to the magnetization. Should have the same shape as magnetization. If provided, the field will be rescaled. Default is None, and no field vector is displayed. time_increments : float or array_like, optional Time increment(s) between consecutive frames. If scalar, the same increment is used for all time points. If array, should match the time dimension of magnetization. Default is 0.1. speed : float or array_like, optional Playback speed multiplier relative to real time, for informational display only. Default is None. traces : tuple of str, optional Trace types to display in the animation. Traces are either the projection of arrow tips onto the surface of the Bloch Sphere (ending in `_projection`) or the 3D historical trajectory of the arrow tips in space, which can be useful to show when a vector has a smaller magnitude. Options include 'magnetization', 'B_field', and their '_projection's. Default is ('magnetization', 'B_field_projection'). engine : str, optional Animation engine to use. Currently only 'manim-cairo' is supported. Default is 'manim-cairo'. prologue : bool, optional Whether to include a prologue sequence in the animation which displays the definition of magnetization and B-field vectors, along with the max B-field amplitude and animaton speed relative to real time. Default is True. preview : bool, optional Whether to preview the animation after rendering. When running in a Jupyter notebook environment, the animation is embedded and displayed as output. Default is True. quality : str, optional Rendering quality. Options include 'low_quality', 'medium_quality', 'high_quality', etc. Default is 'low_quality' since it takes significantly less time to render. Once you are happy with the animation, you can re-render at higher quality for hours to produce high definition production quality visuals. progress_bar : str, optional Progress bar display mode. Default is 'display'. max_files_cached : int, optional Maximum number of cached files for the rendering engine. Default is 1000 as there's many little files for each rotation time step. max_width : int, optional Maximum width percentage for video display in notebooks. Default is 85. **kwargs : dict, optional Additional keyword arguments passed to the manim configuration. Notes ----- Raw Bloch simulation time history signals can be quite large, so consider downsampling the magnetization and B-field signals before passing to this function, but be wary to preserve the key frequency content of the signal and avoid downsampling too far - always inspect your data. See Also -------- manim.ThreeDScene : Base class for creating 3D scenes in Manim. manim.Arrow3D : 3D arrow object in Manim. manim.TracedPath : Trace the path of a moving object in Manim. downsample : Resample time-domain signals via Fourier or filtering methods. gigablochs.backends.manim_cairo.BlochScene : Manim Cairo backend for Bloch sphere animations. """ if np.isscalar(time_increments): time_increments = np.full_like(magnetization, time_increments)[..., 0] if engine == 'manim-cairo': from tqdm.auto import tqdm import manim.scene.scene manim.scene.scene.tqdm = tqdm from manim import config, tempconfig from gigablochs.backends.manim_cairo import BlochScene kwargs['quality'] = quality kwargs['progress_bar'] = progress_bar kwargs['max_files_cached'] = max_files_cached with tempconfig(kwargs): scene = BlochScene() scene.set_data(magnetization, rescale_Beff(B_field) if B_field is not None else (None, None), time_increments, speed, traces, prologue) scene.render(preview and not 'notebook' in SHELL) if preview and 'notebook' in SHELL: vid = Video(config['output_file'], embed=True, html_attributes=f'controls loop style="max-width: {max_width}%;"') display(vid)