Source code for pulserver.virtual._region

"""The slabs a scan's excitation pulses excite, outside which its isochromats carry no transverse magnetization."""

from __future__ import annotations

__all__ = ["Slabs", "excited"]

from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace

import numpy as np

from .. import ir
from ._bloch import _gradients, _rf
from ._isochromats import Isochromats

#: Fraction of the largest change a pulse makes to the magnetization at rest
#: below which a field is outside the slab it excites.
THRESHOLD = 1e-2

# Scales of a pulse's amplitude its slab is found at, spanning the transmit
# fields of a coil over a head.
_SCALES = (1.0, 2.0)

# Half-width, in m, of the line along a pulse's gradient its slab is found on.
_REACH = 0.3

# Points of that line per 1/T, T the pulse's duration.
_POINTS = 16.0

# The cache's use of an excitation pulse, PULSEG_RF_USE_EXCITATION.
_EXCITATION = 1

# Largest change of a gradient during a pulse, relative to its largest axis,
# at which it is held.
_HELD = 1e-6

# Gradients excitations may play under beyond which they excite every
# isochromat: between them, the slabs of a pulse per spoke, as a ZTE scan
# plays, leave out next to none of the object.
_GRADIENTS = 16


[docs] @dataclass(frozen=True) class Slabs: """Slabs of the physical frame, each the isochromats that see a field within its bounds during a pulse. An isochromat at ``r``, in m, precessing at ``f``, in Hz, sees the field ``g . r + f`` during a pulse played under the gradient ``g``, in Hz/m, along the physical axes; it lies in the slab when that field lies within the slab's bounds, in Hz, so that off-resonance moves an isochromat's slab as it moves the pulse's. Attributes ---------- gradients Each slab's gradient, in Hz/m, along the physical axes. bounds Each slab's lowest and highest field, in Hz. """ gradients: tuple[tuple[float, float, float], ...] bounds: tuple[tuple[float, float], ...] def __call__(self, positions: np.ndarray, frequencies: np.ndarray) -> np.ndarray: """Return which of the isochromats at ``(n, 3)`` positions, in m, precessing at ``(n,)`` frequencies, in Hz, lie in a slab.""" positions = np.asarray(positions, dtype=float).reshape(-1, 3) frequencies = np.broadcast_to( np.asarray(frequencies, dtype=float), (len(positions),) ) kept = np.zeros(len(positions), dtype=bool) for gradient, (low, high) in zip(self.gradients, self.bounds, strict=True): field = positions @ np.asarray(gradient) + frequencies kept |= (field >= low) & (field <= high) return kept
[docs] def excited( seq_path: Path | str, rotation: np.ndarray | None = None, *, cache_ext: str = ".pseg", threshold: float = THRESHOLD, ) -> Slabs | None: """Return the slabs the excitation pulses of the cache beside a sequence file excite; None where one excites every isochromat. A pulse's slab holds the fields at which it changes the magnetization at rest by at least ``threshold`` of the most it changes it, at its nominal amplitude or twice it: the pulse is played on a line of isochromats along its gradient, turned by ``rotation`` as :func:`~pulserver.virtual.simulate` turns it. A pulse played without a gradient, or under one that changes during it, excites every isochromat, and so do pulses played under more than 16 gradients, as a ZTE scan's are. Only the pulses the cache labels excitations are counted: what the others tip into the transverse plane outside the slabs, such as the free induction decay of an imperfect refocusing pulse, is left out of a scan simulated in them. """ played = ir.playout(Path(seq_path), waveforms=True, cache_ext=cache_ext)["blocks"] turn = np.eye(3) if rotation is None else np.asarray(rotation, dtype=float) slabs: dict[tuple[float, float, float], set[tuple[float, float]]] = {} profiles: dict[tuple[bytes, bytes], list[tuple[float, float]]] = {} for block in np.flatnonzero(played["rf_use"] == _EXCITATION): start, stop = played["rf_span"][block] if stop <= start or played["rf_amp_hz"][block] == 0.0: continue held = _held(played, block) if held is None: return None gradient = turn @ held if played["rotate"][block] else held if not np.any(gradient): return None if tuple(gradient.tolist()) not in slabs and len(slabs) == _GRADIENTS: return None key = (gradient.tobytes(), played["rf_waveform_hz"][start:stop].tobytes()) if key not in profiles: profiles[key] = _profile(played, block, gradient, turn, threshold) # A frequency offset moves the fields a pulse excites by as much. offset = float(played["rf_freq_hz"][block]) bounds = slabs.setdefault(tuple(gradient.tolist()), set()) bounds.update((low + offset, high + offset) for low, high in profiles[key]) gradients, bounds = [], [] for gradient, held in slabs.items(): for low, high in sorted(held): gradients.append(gradient) bounds.append((low, high)) return Slabs(tuple(gradients), tuple(bounds))
def _held(played: dict, block: int) -> np.ndarray | None: """Return the block's gradient during its RF pulse along the logical axes, in Hz/m; None where it changes during it.""" start, stop = played["rf_span"][block] times = 1e-6 * played["rf_time_us"][start:stop].astype(float) values = np.zeros((3, times.size)) for axis, corners in enumerate(_gradients(played, block)): if corners is not None: values[axis] = np.interp(times, corners[0], corners[1], left=0.0, right=0.0) largest = np.abs(values).max() if np.ptp(values, axis=1).max() > _HELD * largest: return None return values[:, 0].copy() def _profile( played: dict, block: int, gradient: np.ndarray, turn: np.ndarray, threshold: float, ) -> list[tuple[float, float]]: """Return the bounds, in Hz, of the runs of fields ``gradient . r`` at which the block's pulse, played without its frequency offset, excites.""" rf = _rf(played, block) duration = float(np.max(rf.t) + np.min(rf.t)) strength = float(np.linalg.norm(gradient)) step = 1.0 / (_POINTS * duration) fields = np.arange(-_REACH * strength, _REACH * strength + step, step) positions = np.outer(fields / strength, gradient / strength) changed = np.zeros(fields.size) for scale in _SCALES: spins = Isochromats(positions) spins.play( 1e-6 * float(played["duration_us"][block]), gradients=_gradients(played, block), rotation=turn if played["rotate"][block] else None, rf=SimpleNamespace( **{**vars(rf), "signal": scale * rf.signal, "freq_offset": 0.0} ), ) m = spins.magnetization change = np.sqrt(m[:, 0] ** 2 + m[:, 1] ** 2 + (m[:, 2] - 1.0) ** 2) if change.max() > 0.0: changed = np.maximum(changed, change / change.max()) inside = np.concatenate([[0], (changed >= threshold).astype(np.int8), [0]]) edges = np.flatnonzero(np.diff(inside)) return [ (float(fields[first] - step), float(fields[last - 1] + step)) for first, last in zip(edges[::2], edges[1::2], strict=True) ]