Nutate
Bloch simulations of any pulse sequence, rendered as 3D Bloch-sphere videos.
Full-quality video (1080p):
https://github.com/user-attachments/assets/1848f607-e421-4923-b198-bb7745c69010
Nutate takes any pulse, given as a waveform (amplitude and phase, or I and Q) with its
duration and Rabi frequency, whether it drives nuclear spins (NMR), electron spins (EPR/ESR)
or a qubit. It simulates the Bloch vector in the rotating frame for many offsets at once,
renders the trajectories with PyVista as MP4/GIF videos, PNG stills
or an interactive viewer, and maps robustness to drive (B1) amplitude errors. Optional pulse
families (chirps, hyperbolic-secant pulses, CHORUS, composite pulses) live in
nutate.pulses.
Install
pip install nutate
Python ≥ 3.11. Video encoding uses the ffmpeg binary bundled with imageio-ffmpeg; no
system ffmpeg or LaTeX needed.
Quickstart
A generator makes the pulse: a waveform normalised to 1 plus its duration and Rabi frequency
ν1 (the drive amplitude, RF or MW, at waveform value 1). nt.run simulates it and writes
the video and/or the B1 robustness figures.
import nutate as nt
from nutate.pulses.swept_frequency import chirp
pulse = chirp(10e-3, 20e3, 500, smoothing=10) # pulse.amp (peak 1), .phase, .duration, .rabi
nt.run(pulse, 10e3, still=True, b1=True, out="output", name="chirp")
# output/chirp.mp4, chirp.png, chirp_b1_map.png, chirp_b1_surface.png, chirp_b1_phase.png
Units are SI throughout (seconds, hertz, radians); arguments in degrees end in _deg.
The package core holds the pulse type, nt.waveform (your own arrays) and nt.delay (free
precession). Pulse families live in nutate.pulses:
nutate.pulses.swept_frequency:chirp,hs,chorus(a list including its delay),CHORUS_CYCLE,phase_correctionandchorus_phase_correctionnutate.pulses.composite:composite(a list, one pulse per element) andPRESETS
pulse.with_rabi(ν1) changes the Rabi frequency and keeps the waveform.
Your own waveform
nt.waveform turns arrays from any other generator (an optimiser, a shape tool) into a
pulse: amplitude and phase, or I and Q, normalised to 1. rabi is the Rabi frequency in
Hz, or give t90, the hard 90° pulse length (rabi = 1/(4·t90)). Slight overshoot above 1
is simulated as given; a peak above 1.5 is an error.
p1 = nt.waveform(amp, phase, duration=2e-3, rabi=15e3) # or phase_deg=...
p2 = nt.waveform(i=I, q=Q, duration=1e-3, t90=10e-6)
result = nt.run([p1, p2], 300e3, b1=True, out="output", name="my_pulse")
Sequences and delays
A sequence is a list, played back to back. Put nt.delay(duration, n_steps) between
pulses for free precession (no drive); n_steps sets how finely the delay is sampled, i.e.
how smoothly the video shows it.
sequence = [p1, nt.delay(500e-6, 100), p2] # p1, 500 µs of free precession, then p2
nt.run(sequence, 300e3, out="output", name="echo")
nt.run options
- Offsets span
±bandwidth/2:n_offsets(31) for the video,b1_offsets(101) for the B1 map. Or passoffsets=(Hz) for both. video(on),still(last frame as PNG),b1(off),show(interactive windows instead of files). Pulses in a list play back to back;phase_cycle=is passed through.video_optionsgo tont.render; those that apply also go to the still and the viewer:offsets: draw onlykevenly spaced offsets, or given indicesmode:"arrows","ribbon"(a curve through all tips) or"both"trail: fading tip trails, as a fraction of the sequenceinset/inset_phase: drive amplitude (and phase) vs time with a moving cursortheme:"light"(default) or"dark"camera=nt.Camera(orbit=90),style=nt.Style(...)(colours, sizes, font),duration,size
- B1 figures plot
−Myby default;component="z"for inversion.b1_scales(0.5–1.5) andrabi_ref(the highest ν1, for theΔ/Ω1(0)axis) can be changed. - The result holds the trajectory, the B1 map, the phase sensitivity and the files written.
Lower level
import numpy as np
traj = nt.simulate([pulse], offsets=np.linspace(-5e3, 5e3, 31))
nt.render(traj, "chirp.mp4", duration=12)
nt.snapshot(...) writes a PNG still; nt.show(traj) opens an interactive window with a
time slider. nt.plot_profiles(traj) and nt.plot_map(traj) make the 2D excitation
profile and time–offset map plots.
B1 robustness
pulse = nt.waveform([1.0], [0.0], duration=10e-6, t90=10e-6) # hard 90°, ν1 = 25 kHz
offsets = np.linspace(-100e3, 100e3, 101) # Δ/Ω1(0) from −4 to 4
scales = np.linspace(0.5, 1.5, 101) # Ω1 / Ω1(0)
bmap = nt.b1_map([pulse], offsets, scales)
nt.plot_b1_map(bmap, "y", sign=-1) # contours over Δ/Ω1(0) × Ω1/Ω1(0)
nt.plot_b1_map(bmap, "y", sign=-1, surface=True)
nt.plot_b1_phase(scales, nt.b1_phase_sensitivity([pulse], scales))
Sequences whose phase varies quickly across the band, such as a single chirp, need dense
offsets; examples/b1_chirp.py uses 1000, as the original script did.
simulate(..., b1=...) scales every drive amplitude (one factor, or one per offset);
simulate_final returns only the final states, which keeps large grids small in memory.
nutate.pulses.swept_frequency.phase_correction(sequence, bandwidth, ...) fits the
first-chirp phase correction for any CHORUS-like sequence.
Examples
Each script generates its pulses and passes them to nt.run. Video scripts write
output/<name>.mp4 and .png; the b1_* scripts (and waveform.py) write
output/<name>_b1_map.png, _b1_surface.png and _b1_phase.png. To change what they
produce, edit the nt.run call: show=True for interactive windows, out= for another
folder, video_options={"theme": "dark"} for a dark video.
| Example | Shows |
|---|---|
examples/chirp.py |
linear chirp excitation |
examples/chirp_rewind.py |
a chirp, then an ideal rewind of its quadratic phase |
examples/hard_pulse.py |
a hard 90° pulse over a wide offset range |
examples/composite.py |
a composite pulse (preset 9 on a 10 µs 90° pulse) |
examples/chorus.py |
CHORUS with phase correction, plus excitation profiles |
examples/compressed_chorus_epr.py |
compressed CHORUS for EPR, plus a time–offset map and phase paths |
examples/waveform.py |
your own I/Q arrays (a shifted Gaussian 90°): video, still and B1 figures |
examples/b1_hard.py |
B1 map and phase sensitivity: hard 90° pulse |
examples/b1_composite.py |
B1 map and phase sensitivity: composite pulses |
examples/b1_chirp.py |
B1 map and phase sensitivity: single chirp |
examples/b1_double_chirp.py |
B1 map and phase sensitivity: double chirp |
examples/b1_chorus.py |
B1 map and phase sensitivity: CHORUS |
examples/b1_abstruse.py |
B1 map and phase sensitivity: ABSTRUSE (hyperbolic-secant pulses) |
Conventions
- Rotating frame, no relaxation; initial magnetization
(0, 0, 1). - Each step rotates the magnetization by
+α = 2π·sqrt(Ω² + Δ²)·dt(right-handed) aboutn = (sinθ cosφ, sinθ sinφ, cosθ)withθ = atan2(Ω, Δ), for drive amplitudeΩ(waveform × ν1), phaseφand offsetΔ— i.e.Rz(φ)·Ry(θ)·Rz(α)·Ry(−θ)·Rz(−φ). So an on-resonance 90° pulse with phase 0 takesz → −y. - Profile phase is
angle(My + i·Mx).
Headless rendering
Videos are rendered off-screen. On a server without a display, VTK needs an off-screen OpenGL context (OSMesa/EGL or a virtual display); see the off-screen rendering notes in the PyVista documentation.
Citation
If you use nutate in published work, please cite it; see CITATION.cff.
License
Metadata
Release files for nutate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nutate-0.1.0.tar.gz | 135.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nutate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 171.4 kB
Release files / nutate-0.1.0.tar.gz
| Download URL | nutate-0.1.0.tar.gz |
|---|---|
| Size | 135.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e5be6bd14a87720c02c7cc7aa7bc498856bbdcfe988125a5fe42c396de551263
|
|
BLAKE2b-256 checksum How to use checksums |
a0c7351b470a6fd54b3c6b603fa2df5d10f6542669e5ffb364cefee50ee67aa9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logRelease files / nutate-0.1.0-py3-none-any.whl
| Download URL | nutate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 36.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f847da0db28327667df45138c1957342c18de145ea52b1be3b531239ef8fee43
|
|
BLAKE2b-256 checksum How to use checksums |
b1948a7179f92b71068b3b6752f6b18018e0aedf8556b4e6dfde23e900035656
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency log