Skip to content

helicon.fields

Magnetic field computation: Biot-Savart solver, field line tracing, FRC topology, and caching.

Core API

helicon.fields.biot_savart.Coil(z, r, I) dataclass

A single circular current loop in SI units.

Parameters:

Name Type Description Default
z float

Axial position of the coil centre [m].

required
r float

Radius of the coil [m].

required
I float

Current (ampere-turns) [A].

required

helicon.fields.biot_savart.Grid(z_min, z_max, r_max, nz, nr) dataclass

Axisymmetric (r, z) computation grid.

The grid spans z ∈ [z_min, z_max] and r ∈ [0, r_max].

Parameters:

Name Type Description Default
z_min float

Axial extent [m].

required
z_max float

Axial extent [m].

required
r_max float

Radial extent [m].

required
nz int

Number of grid points along each axis.

required
nr int

Number of grid points along each axis.

required

helicon.fields.biot_savart.BField(Br, Bz, r, z, coils, backend) dataclass

Result container — always stores NumPy arrays for interop.

Attributes:

Name Type Description
Br, Bz ndarray

Radial and axial magnetic field components [T], shape (nr, nz).

r, z ndarray

1-D coordinate arrays [m].

coils list[Coil]

Coil configuration that produced this field.

backend str

Backend used ("numpy" or "mlx").

Functions

load(path) classmethod

Load a previously saved BField from HDF5.

Source code in src/helicon/fields/biot_savart.py
@classmethod
def load(cls, path: str) -> BField:
    """Load a previously saved BField from HDF5."""
    if not HAS_H5PY:
        raise ImportError("h5py is required for BField.load()")
    with h5py.File(path, "r") as f:
        Br = f["Br"][:]
        Bz = f["Bz"][:]
        r = f["r"][:]
        z = f["z"][:]
        backend = f.attrs["backend"]
        n_coils = int(f.attrs["n_coils"])
        coils = [
            Coil(
                z=float(f.attrs[f"coil_{i}_z"]),
                r=float(f.attrs[f"coil_{i}_r"]),
                I=float(f.attrs[f"coil_{i}_I"]),
            )
            for i in range(n_coils)
        ]
    return cls(Br=Br, Bz=Bz, r=r, z=z, coils=coils, backend=backend)

plot(*, component='Bz', field_lines=True, n_field_lines=8, ax=None, cmap='RdBu_r', figsize=(10, 5))

Plot the B-field on the (z, r) plane.

Parameters:

Name Type Description Default
component ``"Bz"`` | ``"Br"`` | ``"Bmag"``

Field component to show as a filled contour.

'Bz'
field_lines bool

Overlay field lines via contours of the flux function ψ.

True
n_field_lines int

Number of field line contours to draw.

8
ax matplotlib Axes

Axes to draw on. Creates new figure if None.

None
cmap str

Matplotlib colormap.

'RdBu_r'
figsize tuple

Figure size when creating a new figure.

(10, 5)

Returns:

Type Description
(fig, ax)

Matplotlib figure and axes.

Source code in src/helicon/fields/biot_savart.py
def plot(
    self,
    *,
    component: str = "Bz",
    field_lines: bool = True,
    n_field_lines: int = 8,
    ax=None,
    cmap: str = "RdBu_r",
    figsize: tuple = (10, 5),
):
    """Plot the B-field on the (z, r) plane.

    Parameters
    ----------
    component : ``"Bz"`` | ``"Br"`` | ``"Bmag"``
        Field component to show as a filled contour.
    field_lines : bool
        Overlay field lines via contours of the flux function ψ.
    n_field_lines : int
        Number of field line contours to draw.
    ax : matplotlib Axes, optional
        Axes to draw on. Creates new figure if None.
    cmap : str
        Matplotlib colormap.
    figsize : tuple
        Figure size when creating a new figure.

    Returns
    -------
    fig, ax
        Matplotlib figure and axes.
    """
    try:
        import matplotlib.pyplot as plt
    except ImportError as exc:
        raise ImportError("matplotlib is required for BField.plot()") from exc

    if ax is None:
        fig, ax = plt.subplots(figsize=figsize)
    else:
        fig = ax.get_figure()

    Z, R = np.meshgrid(self.z, self.r)

    if component == "Bz":
        values = self.Bz
        label = "$B_z$ (T)"
    elif component == "Br":
        values = self.Br
        label = "$B_r$ (T)"
    elif component == "Bmag":
        values = np.sqrt(self.Br**2 + self.Bz**2)
        label = "$|B|$ (T)"
    else:
        raise ValueError(f"Unknown component {component!r}. Use 'Bz', 'Br', or 'Bmag'.")

    pcm = ax.pcolormesh(Z, R, values, cmap=cmap, shading="auto")
    fig.colorbar(pcm, ax=ax, label=label)

    if field_lines:
        # Flux function ψ(r,z) = cumulative integral of r*B_z over r (up to sign)
        dr = self.r[1] - self.r[0] if len(self.r) > 1 else 1.0
        psi = np.cumsum(self.r[:, None] * self.Bz, axis=0) * dr
        with contextlib.suppress(Exception):
            ax.contour(Z, R, psi, n_field_lines, colors="k", linewidths=0.6, alpha=0.5)

    # Mark coil positions
    for coil in self.coils:
        ax.plot(coil.z, coil.r, "ko", ms=6, zorder=5)
        ax.plot(coil.z, -coil.r if min(self.r) < 0 else coil.r, "ko", ms=6, zorder=5)

    ax.set_xlabel("z (m)")
    ax.set_ylabel("r (m)")
    ax.set_title(f"Magnetic Field — {component}")

    return fig, ax

save(path)

Write field data + coil metadata to an HDF5 file.

Source code in src/helicon/fields/biot_savart.py
def save(self, path: str) -> None:
    """Write field data + coil metadata to an HDF5 file."""
    if not HAS_H5PY:
        raise ImportError("h5py is required for BField.save()")
    with h5py.File(path, "w") as f:
        f.create_dataset("Br", data=self.Br)
        f.create_dataset("Bz", data=self.Bz)
        f.create_dataset("r", data=self.r)
        f.create_dataset("z", data=self.z)
        f.attrs["backend"] = self.backend
        f.attrs["n_coils"] = len(self.coils)
        for i, c in enumerate(self.coils):
            f.attrs[f"coil_{i}_z"] = c.z
            f.attrs[f"coil_{i}_r"] = c.r
            f.attrs[f"coil_{i}_I"] = c.I

helicon.fields.biot_savart.compute_bfield(coils, grid, *, backend='auto', n_phi=128, differentiable=False)

Compute the applied magnetic field from a set of circular coils.

Parameters:

Name Type Description Default
coils sequence of Coil

Coil definitions in SI units.

required
grid Grid

Computational domain.

required
backend ``"auto"`` | ``"mlx"`` | ``"numpy"``

"auto" uses MLX if available, else NumPy.

'auto'
n_phi int

Azimuthal quadrature points (MLX backend only).

128
differentiable bool

When True, use the loop-based MLX path so the computation graph is preserved for mx.grad(). When False (default), use the vectorized + compiled path for faster non-gradient evaluation.

False

Returns:

Type Description
BField

Result container with NumPy arrays.

Source code in src/helicon/fields/biot_savart.py
def compute_bfield(
    coils: Sequence[Coil],
    grid: Grid,
    *,
    backend: str = "auto",
    n_phi: int = 128,
    differentiable: bool = False,
) -> BField:
    """Compute the applied magnetic field from a set of circular coils.

    Parameters
    ----------
    coils : sequence of Coil
        Coil definitions in SI units.
    grid : Grid
        Computational domain.
    backend : ``"auto"`` | ``"mlx"`` | ``"numpy"``
        ``"auto"`` uses MLX if available, else NumPy.
    n_phi : int
        Azimuthal quadrature points (MLX backend only).
    differentiable : bool
        When True, use the loop-based MLX path so the computation graph is
        preserved for ``mx.grad()``.  When False (default), use the
        vectorized + compiled path for faster non-gradient evaluation.

    Returns
    -------
    BField
        Result container with NumPy arrays.
    """
    coils = list(coils)

    if backend == "auto":
        backend = "mlx" if HAS_MLX else "numpy"

    if backend == "mlx":
        if not HAS_MLX:
            raise ImportError("MLX is not installed. Install with: pip install 'helicon[mlx]'")
        Br, Bz, r, z = _compute_mlx(coils, grid, n_phi=n_phi, differentiable=differentiable)
    elif backend == "numpy":
        Br, Bz, r, z = _compute_numpy(coils, grid)
    else:
        raise ValueError(f"Unknown backend: {backend!r}. Use 'auto', 'mlx', or 'numpy'.")

    return BField(Br=Br, Bz=Bz, r=r, z=z, coils=coils, backend=backend)

helicon.fields.biot_savart.compute_bfield_mlx_differentiable(coil_params, grid_r, grid_z, n_phi=128)

MLX-native differentiable Biot-Savart computation.

Designed for composition with mlx.core.grad(). Does not call mx.eval so the computation graph is preserved.

Parameters:

Name Type Description Default
coil_params (array, shape(N_coils, 3))

Each row is [z_coil, radius, current] in SI.

required
grid_r (array, shape(N_pts))

Flattened grid coordinates.

required
grid_z (array, shape(N_pts))

Flattened grid coordinates.

required
n_phi int

Number of azimuthal quadrature points.

128

Returns:

Type Description
Br, Bz : mx.array, shape (N_pts,)
Source code in src/helicon/fields/biot_savart.py
def compute_bfield_mlx_differentiable(
    coil_params: mx.array,
    grid_r: mx.array,
    grid_z: mx.array,
    n_phi: int = 128,
) -> tuple[mx.array, mx.array]:
    """MLX-native differentiable Biot-Savart computation.

    Designed for composition with ``mlx.core.grad()``.  Does **not** call
    ``mx.eval`` so the computation graph is preserved.

    Parameters
    ----------
    coil_params : mx.array, shape (N_coils, 3)
        Each row is ``[z_coil, radius, current]`` in SI.
    grid_r, grid_z : mx.array, shape (N_pts,)
        Flattened grid coordinates.
    n_phi : int
        Number of azimuthal quadrature points.

    Returns
    -------
    Br, Bz : mx.array, shape (N_pts,)
    """
    if not HAS_MLX:
        raise ImportError("MLX is required for compute_bfield_mlx_differentiable")

    phi = mx.linspace(0.0, 2.0 * math.pi, n_phi + 1)[:-1]  # exclude endpoint
    cos_phi = mx.cos(phi)  # (N_phi,)

    N_pts = grid_r.shape[0]
    N_coils = coil_params.shape[0]

    Br_total = mx.zeros((N_pts,))
    Bz_total = mx.zeros((N_pts,))

    for i in range(N_coils):
        z_c = coil_params[i, 0]
        a = coil_params[i, 1]
        I_c = coil_params[i, 2]

        dz = grid_z - z_c  # (N_pts,)

        # Broadcast: r -> (N_pts, 1), cos_phi -> (1, N_phi)
        r_2d = mx.reshape(grid_r, (N_pts, 1))
        dz_2d = mx.reshape(dz, (N_pts, 1))
        cos_2d = mx.reshape(cos_phi, (1, n_phi))

        R_sq = r_2d * r_2d + a * a - 2.0 * a * r_2d * cos_2d + dz_2d * dz_2d
        R_sq = mx.maximum(R_sq, 1e-20)  # singularity guard

        R_inv3 = R_sq ** (-1.5)

        prefactor = MU_0 * I_c * a / (2.0 * n_phi)

        # Sum over phi (axis=1)
        Br_total = Br_total + prefactor * mx.sum(cos_2d * dz_2d * R_inv3, axis=1)
        Bz_total = Bz_total + prefactor * mx.sum((a - r_2d * cos_2d) * R_inv3, axis=1)

    return Br_total, Bz_total

Field Cache

helicon.fields.cache.FieldCache(cache_dir=None)

On-disk HDF5 cache for BField results.

Source code in src/helicon/fields/cache.py
def __init__(self, cache_dir: str | pathlib.Path | None = None) -> None:
    if cache_dir is None:
        cache_dir = pathlib.Path.home() / ".cache" / "helicon" / "bfield"
    self._dir = pathlib.Path(cache_dir)
    self._dir.mkdir(parents=True, exist_ok=True)

Functions

clear()

Delete all .h5 files in the cache directory.

Source code in src/helicon/fields/cache.py
def clear(self) -> None:
    """Delete all .h5 files in the cache directory."""
    for f in self._dir.glob("*.h5"):
        f.unlink()

get(coils, grid)

Load a cached BField, or return None on miss / corrupt file.

Source code in src/helicon/fields/cache.py
def get(self, coils: Sequence[Coil], grid: Grid) -> BField | None:
    """Load a cached BField, or return None on miss / corrupt file."""
    path = self._dir / f"{self.key(coils, grid)}.h5"
    if not path.exists():
        return None
    try:
        return BField.load(str(path))
    except Exception:
        return None

key(coils, grid)

SHA-256 cache key (first 16 hex chars) from coil + grid params.

Source code in src/helicon/fields/cache.py
def key(self, coils: Sequence[Coil], grid: Grid) -> str:
    """SHA-256 cache key (first 16 hex chars) from coil + grid params."""
    coil_data = sorted(
        [{"z": c.z, "r": c.r, "I": c.I} for c in coils],
        key=lambda d: (d["z"], d["r"], d["I"]),
    )
    grid_data = {
        "z_min": grid.z_min,
        "z_max": grid.z_max,
        "r_max": grid.r_max,
        "nz": grid.nz,
        "nr": grid.nr,
    }
    blob = json.dumps({"coils": coil_data, "grid": grid_data}, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()[:16]

put(coils, grid, bfield)

Save a BField to the cache.

Source code in src/helicon/fields/cache.py
def put(self, coils: Sequence[Coil], grid: Grid, bfield: BField) -> None:
    """Save a BField to the cache."""
    path = self._dir / f"{self.key(coils, grid)}.h5"
    bfield.save(str(path))

size()

Number of cached .h5 files.

Source code in src/helicon/fields/cache.py
def size(self) -> int:
    """Number of cached .h5 files."""
    return len(list(self._dir.glob("*.h5")))

helicon.fields.cache.compute_bfield_cached(coils, grid, *, cache=None, backend='auto', n_phi=128)

Like compute_bfield but with transparent HDF5 caching.

Source code in src/helicon/fields/cache.py
def compute_bfield_cached(
    coils: Sequence[Coil],
    grid: Grid,
    *,
    cache: FieldCache | None = None,
    backend: str = "auto",
    n_phi: int = 128,
) -> BField:
    """Like ``compute_bfield`` but with transparent HDF5 caching."""
    if cache is None:
        cache = get_default_cache()

    hit = cache.get(coils, grid)
    if hit is not None:
        return hit

    bf = compute_bfield(coils, grid, backend=backend, n_phi=n_phi)
    cache.put(coils, grid, bf)
    return bf

FRC Topology

helicon.fields.frc_topology.FRCTopologyResult(is_open, is_closed, psi_rz, o_point_z, o_point_r, x_point_z, psi_separatrix) dataclass

Topology classification of an FRC magnetic field.

helicon.fields.frc_topology.find_frc_topology(bfield)

Classify every grid point as open or closed for an FRC field.

Parameters:

Name Type Description Default
bfield BField

Magnetic field on an axisymmetric (r, z) grid.

required

Returns:

Type Description
FRCTopologyResult
Source code in src/helicon/fields/frc_topology.py
def find_frc_topology(bfield: BField) -> FRCTopologyResult:
    """Classify every grid point as open or closed for an FRC field.

    Parameters
    ----------
    bfield : BField
        Magnetic field on an axisymmetric (r, z) grid.

    Returns
    -------
    FRCTopologyResult
    """
    r = bfield.r
    nr = len(r)
    dr = r[1] - r[0] if nr > 1 else 1.0

    psi = compute_flux_function(bfield.Bz, r, dr)

    # --- O-point: max |psi| in interior (exclude boundary rows/cols) ---
    interior = psi[1:-1, 1:-1] if nr > 2 and psi.shape[1] > 2 else psi
    abs_interior = np.abs(interior)
    if interior.size == 0:
        # Degenerate grid
        o_idx_r, o_idx_z = 0, 0
    else:
        flat_idx = np.argmax(abs_interior)
        o_idx_r_int, o_idx_z_int = np.unravel_index(flat_idx, interior.shape)
        # Offset back to full-grid indices
        o_idx_r = o_idx_r_int + (1 if nr > 2 and psi.shape[1] > 2 else 0)
        o_idx_z = o_idx_z_int + (1 if nr > 2 and psi.shape[1] > 2 else 0)

    o_point_r = float(r[o_idx_r])
    o_point_z = float(bfield.z[o_idx_z])
    psi_o = psi[o_idx_r, o_idx_z]

    # --- X-point / separatrix ---
    # For FRC: separatrix is where psi = 0 (reversed field makes psi cross zero).
    # Find the X-point as the location on the axis (r index closest to O-point r)
    # where psi crosses zero downstream of the O-point.
    psi_separatrix = 0.0

    # Look for sign change along the r = o_idx_r slice, downstream of O-point
    psi_slice = psi[o_idx_r, :]
    x_point_z = float(bfield.z[-1])  # default: end of domain

    # Search for zero crossing downstream (z > o_point_z)
    z_arr = bfield.z
    downstream = np.where(z_arr > o_point_z)[0]
    found_x = False
    for j in range(len(downstream) - 1):
        j0 = downstream[j]
        j1 = downstream[j + 1]
        if psi_slice[j0] * psi_slice[j1] < 0:
            # Linear interpolation for zero crossing
            frac = psi_slice[j0] / (psi_slice[j0] - psi_slice[j1])
            x_point_z = float(z_arr[j0] + frac * (z_arr[j1] - z_arr[j0]))
            found_x = True
            break

    if not found_x:
        # Also search upstream (z < o_point_z) as fallback
        upstream = np.where(z_arr < o_point_z)[0]
        for j in range(len(upstream) - 1, 0, -1):
            j0 = upstream[j - 1]
            j1 = upstream[j]
            if psi_slice[j0] * psi_slice[j1] < 0:
                frac = psi_slice[j0] / (psi_slice[j0] - psi_slice[j1])
                x_point_z = float(z_arr[j0] + frac * (z_arr[j1] - z_arr[j0]))
                found_x = True
                break

    # --- Classify open / closed ---
    if psi_o > 0:
        is_closed = psi > psi_separatrix
    elif psi_o < 0:
        is_closed = psi < psi_separatrix
    else:
        # Degenerate: no FRC topology, everything open
        is_closed = np.zeros_like(psi, dtype=bool)

    is_open = ~is_closed

    return FRCTopologyResult(
        is_open=is_open,
        is_closed=is_closed,
        psi_rz=psi,
        o_point_z=o_point_z,
        o_point_r=o_point_r,
        x_point_z=x_point_z,
        psi_separatrix=psi_separatrix,
    )

Field Lines

helicon.fields.field_lines.trace_field_lines(bfield, *, n_lines=20, z_start=None, r_range=None, max_length=100.0, max_steps=10000, ds=0.001)

Trace multiple field lines from evenly spaced starting points.

Parameters:

Name Type Description Default
bfield BField

Magnetic field data.

required
n_lines int

Number of field lines to trace.

20
z_start float

Axial position to launch lines from. Defaults to domain midpoint.

None
r_range (r_min, r_max)

Radial range for starting points. Defaults to (0, r_max).

None
max_length float

Maximum arc length to trace [m].

100.0
max_steps int

Maximum integration steps.

10000
ds float

Initial step size [m].

0.001
Source code in src/helicon/fields/field_lines.py
def trace_field_lines(
    bfield: BField,
    *,
    n_lines: int = 20,
    z_start: float | None = None,
    r_range: tuple[float, float] | None = None,
    max_length: float = 100.0,
    max_steps: int = 10000,
    ds: float = 0.001,
) -> FieldLineSet:
    """Trace multiple field lines from evenly spaced starting points.

    Parameters
    ----------
    bfield : BField
        Magnetic field data.
    n_lines : int
        Number of field lines to trace.
    z_start : float, optional
        Axial position to launch lines from. Defaults to domain midpoint.
    r_range : (r_min, r_max), optional
        Radial range for starting points. Defaults to (0, r_max).
    max_length : float
        Maximum arc length to trace [m].
    max_steps : int
        Maximum integration steps.
    ds : float
        Initial step size [m].
    """
    if z_start is None:
        z_start = 0.5 * (bfield.z[0] + bfield.z[-1])
    if r_range is None:
        r_range = (0.01 * bfield.r[-1], 0.95 * bfield.r[-1])

    r_starts = np.linspace(r_range[0], r_range[1], n_lines)

    lines = []
    for r0 in r_starts:
        line = trace_field_line(
            bfield,
            float(r0),
            z_start,
            max_length=max_length,
            max_steps=max_steps,
            ds=ds,
        )
        lines.append(line)

    psi = compute_flux_function(bfield)

    # Find separatrix ψ (if any separatrix lines exist)
    sep_psi = None
    sep_lines = [l for l in lines if l.line_type == FieldLineType.SEPARATRIX]
    if sep_lines:
        sep_psi = float(np.mean([l.psi_start for l in sep_lines]))

    return FieldLineSet(
        lines=lines,
        psi=psi,
        r=bfield.r,
        z=bfield.z,
        separatrix_psi=sep_psi,
    )

helicon.fields.field_lines.FieldLine(r, z, line_type, start_r, start_z, psi_start) dataclass

A single traced field line.

helicon.fields.field_lines.FieldLineSet(lines, psi, r, z, separatrix_psi) dataclass

Collection of traced field lines with topology info.

External Import

helicon.fields.import_external.load_csv_bfield(path, *, r_col='r', z_col='z', Br_col='Br', Bz_col='Bz', delimiter=',')

Load a B-field map from a CSV file.

The CSV must have a header row and at minimum four columns for r, z, Br, Bz. Data must lie on a regular meshgrid — all combinations of the unique r and z values must be present.

Parameters:

Name Type Description Default
path str or Path
required
r_col str

Column names in the header row.

'r'
z_col str

Column names in the header row.

'r'
Br_col str

Column names in the header row.

'r'
Bz_col str

Column names in the header row.

'r'
delimiter str

Field delimiter (default ",").

','

Returns:

Type Description
BField

Arrays shaped (nr, nz).

Source code in src/helicon/fields/import_external.py
def load_csv_bfield(
    path: str | Path,
    *,
    r_col: str = "r",
    z_col: str = "z",
    Br_col: str = "Br",
    Bz_col: str = "Bz",
    delimiter: str = ",",
) -> BField:
    """Load a B-field map from a CSV file.

    The CSV must have a header row and at minimum four columns for
    ``r``, ``z``, ``Br``, ``Bz``.  Data must lie on a regular meshgrid —
    all combinations of the unique ``r`` and ``z`` values must be present.

    Parameters
    ----------
    path : str or Path
    r_col, z_col, Br_col, Bz_col : str
        Column names in the header row.
    delimiter : str
        Field delimiter (default ``","``).

    Returns
    -------
    BField
        Arrays shaped ``(nr, nz)``.
    """
    path = Path(path)
    data = np.genfromtxt(path, delimiter=delimiter, names=True, dtype=float)

    r_flat = data[r_col]
    z_flat = data[z_col]
    Br_flat = data[Br_col]
    Bz_flat = data[Bz_col]

    r_unique = np.unique(r_flat)
    z_unique = np.unique(z_flat)
    nr, nz = len(r_unique), len(z_unique)

    if nr * nz != len(r_flat):
        raise ValueError(
            f"CSV has {len(r_flat)} rows but {nr} unique r values and "
            f"{nz} unique z values — does not form a {nr}×{nz} meshgrid."
        )

    Br = Br_flat.reshape(nr, nz)
    Bz = Bz_flat.reshape(nr, nz)

    return BField(Br=Br, Bz=Bz, r=r_unique, z=z_unique, coils=[], backend="csv")

helicon.fields.import_external.load_femm_bfield(path, *, length_scale=0.001)

Load a B-field map exported from FEMM in text (.ans) format.

FEMM axisymmetric analyses can export field data as a tab-delimited table. The expected columns are r z Br Bz |B| (coordinates in mm by default).

Parameters:

Name Type Description Default
path str or Path

Path to the FEMM .ans export file.

required
length_scale float

Multiply coordinates by this factor to convert to SI metres. Default 1e-3 converts FEMM's default millimetres → metres.

0.001

Returns:

Type Description
BField
Source code in src/helicon/fields/import_external.py
def load_femm_bfield(
    path: str | Path,
    *,
    length_scale: float = 1e-3,
) -> BField:
    """Load a B-field map exported from FEMM in text (.ans) format.

    FEMM axisymmetric analyses can export field data as a tab-delimited
    table.  The expected columns are ``r  z  Br  Bz  |B|`` (coordinates
    in mm by default).

    Parameters
    ----------
    path : str or Path
        Path to the FEMM ``.ans`` export file.
    length_scale : float
        Multiply coordinates by this factor to convert to SI metres.
        Default 1e-3 converts FEMM's default millimetres → metres.

    Returns
    -------
    BField
    """
    path = Path(path)
    rows: list[tuple[float, float, float, float]] = []

    with path.open() as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("[") or line.startswith("Block"):
                continue
            try:
                parts = line.split()
                if len(parts) >= 4:
                    r, z, Br, Bz = (
                        float(parts[0]),
                        float(parts[1]),
                        float(parts[2]),
                        float(parts[3]),
                    )
                    rows.append((r, z, Br, Bz))
            except ValueError:
                continue

    if not rows:
        raise ValueError(f"No valid field data found in {path}")

    arr = np.array(rows)
    r_flat = arr[:, 0] * length_scale
    z_flat = arr[:, 1] * length_scale
    Br_flat = arr[:, 2]
    Bz_flat = arr[:, 3]

    r_unique = np.unique(r_flat)
    z_unique = np.unique(z_flat)
    nr, nz = len(r_unique), len(z_unique)

    Br = np.zeros((nr, nz))
    Bz = np.zeros((nr, nz))

    for ri, zi, br, bz in zip(r_flat, z_flat, Br_flat, Bz_flat):
        i = int(np.searchsorted(r_unique, ri))
        j = int(np.searchsorted(z_unique, zi))
        Br[i, j] = br
        Bz[i, j] = bz

    return BField(Br=Br, Bz=Bz, r=r_unique, z=z_unique, coils=[], backend="femm")