boss_bench

Source code for boss_bench.trajectory

"""Trajectory benchmark: predict guiding-center trajectories from their first sample."""

import h5py
import numpy as np
from scipy.interpolate import CubicSpline

from ._checks import require_finite
from .confinement import LOSS_TOL
from .data import _config_index, _read_rows

DT = 1e-6
T_MAX = 1e-3
N_STEPS = 999
TABLE_STEPS = np.array([10, 100, 999])  # the lead times scored [us]
# Speed of a 3.5 MeV alpha particle, v = sqrt(2 E / m) (non-relativistic, as in the tracer) [m/s].
V_ALPHA = np.sqrt(2 * 3.5e6 * 1.602176634e-19 / 6.6446573450e-27)

# Channels of the embedded state in each group, and the group's frozen RMS over the training set.
CHANNEL_GROUPS = {
    "pol_x": (slice(0, 1), 0.3958040045685409),
    "pol_y": (slice(1, 2), 0.4034089137677494),
    "phi": (slice(2, 4), 1.0),
    "lambda": (slice(4, 5), 0.5778076495076678),
}


[docs] def embed_state(s, theta, phi, vpar): r"""Return the embedded form of a guiding-center state, in which models take and return states. .. math:: z = \left(\sqrt{s}\cos\theta,\ \sqrt{s}\sin\theta,\ \cos\varphi,\ \sin\varphi,\ v_\parallel / v_\alpha\right), with :math:`v_\alpha` the speed of a 3.5 MeV alpha particle. Args: s (np.ndarray): normalized toroidal flux, any shape. theta (np.ndarray): Boozer poloidal angle [rad], the shape of ``s``. phi (np.ndarray): Boozer toroidal angle [rad], the shape of ``s``. vpar (np.ndarray): parallel velocity [m/s], the shape of ``s``. Returns: np.ndarray: the embedded states, shape ``(..., 5)``, where ``...`` is the shape of ``s``. """ r = np.sqrt(s) return np.stack([r * np.cos(theta), r * np.sin(theta), np.cos(phi), np.sin(phi), vpar / V_ALPHA], axis=-1)
[docs] def load_trajectory(f, config, particle): """Read one trajectory of one configuration of a trajectory file. The trajectory is returned as stored: its time ``t`` [s], normalized toroidal flux ``s``, Boozer angles ``theta`` and ``zeta`` [rad], and parallel velocity ``vpar`` [m/s], each of shape ``(n_samples,)``. The samples are 1 µs apart, starting at :math:`t = 1` µs; the trajectory of a lost particle ends with one more sample, at the time it was lost. Args: f (h5py.File): an open trajectory file. config (int or str): the row index in the file or the ``uuid`` of the configuration. particle (int): the index of the trajectory among the configuration's trajectories. Returns: dict: **t, s, theta, zeta, vpar**, the samples of the trajectory. Raises: KeyError: if no configuration in the file has this ``uuid``. IndexError: if the configuration has no trajectory with index ``particle``. """ i = _config_index(f, config) c0, c1 = f["config_offsets"][i], f["config_offsets"][i + 1] if not 0 <= particle < c1 - c0: raise IndexError(f"configuration {config} has {c1 - c0} trajectories; there is no trajectory {particle}") a, b = f["traj_offsets"][c0 + particle], f["traj_offsets"][c0 + particle + 1] t, s, theta, zeta, vpar = f["traj_data"][a:b].T return {"t": t, "s": s, "theta": theta, "zeta": zeta, "vpar": vpar}
[docs] def to_xyz(record, s, theta, zeta): r"""Compute the Cartesian positions of points given in Boozer coordinates, such as the samples of a trajectory. :math:`R`, :math:`Z` and :math:`\nu` are interpolated from the configuration's Boozer grid, spectrally in the angles and with a cubic spline in :math:`\sqrt{s}`. With the cylindrical toroidal angle :math:`\phi = \zeta - \nu`, .. math:: (x, y, z) = (R \cos\phi,\ R \sin\phi,\ Z). Args: record (dict): the configuration's fields from ``data.load_record``, including ``R_boozer``, ``Z_boozer``, ``nu_boozer``, ``s1d`` and ``nfp``. s (np.ndarray): normalized toroidal flux, any shape. theta (np.ndarray): Boozer poloidal angle [rad], the shape of ``s``. zeta (np.ndarray): Boozer toroidal angle [rad], the shape of ``s``. Returns: np.ndarray: the Cartesian positions :math:`(x, y, z)` in meters, shape ``(..., 3)``, where ``...`` is the shape of ``s``. Raises: ValueError: if an input contains NaN or inf. """ require_finite(s=s, theta=theta, zeta=zeta) s, theta, zeta = np.broadcast_arrays(*(np.asarray(a, dtype=float) for a in (s, theta, zeta))) shape = s.shape s, theta, zeta = s.ravel(), theta.ravel(), zeta.ravel() s1d, nfp = np.asarray(record["s1d"], dtype=float), int(record["nfp"]) # the cubic spline through the flux surfaces is linear in their values: weights per point weights = CubicSpline(np.sqrt(s1d), np.eye(len(s1d)))(np.sqrt(np.clip(s, 0, None))) R, Z, nu = (_interpolate_boozer(record[name], len(s1d), nfp, weights, theta, zeta) for name in ("R_boozer", "Z_boozer", "nu_boozer")) phi = zeta - nu return np.stack([R * np.cos(phi), R * np.sin(phi), Z], axis=-1).reshape(shape + (3,))
def _interpolate_boozer(values, n_s, nfp, weights, theta, zeta, block=4096): """Interpolate a field on the Boozer grid at points, spectrally in the angles. Args: values (array_like): the field, flattened over (s, zeta, theta), on uniform angle grids that start at 0 and span one field period in zeta and 2 pi in theta. n_s (int): number of flux surfaces of the grid. nfp (int): number of field periods. weights (np.ndarray): radial interpolation weights of each point on each surface, shape (n, n_s). theta, zeta (np.ndarray): Boozer angles of the points [rad], shape (n,). block (int): points evaluated at once. Returns: np.ndarray: the field at the points, shape (n,). """ n = int(round(np.sqrt(np.size(values) / n_s))) # the zeta and theta grids have n points each grid = np.asarray(values, dtype=float).reshape(n_s, n, n) coef = np.fft.fft2(grid, axes=(1, 2)) / (grid.shape[1] * grid.shape[2]) k_zeta = np.fft.fftfreq(grid.shape[1], 1 / grid.shape[1]) * nfp k_theta = np.fft.fftfreq(grid.shape[2], 1 / grid.shape[2]) out = np.empty(len(theta)) for a in range(0, len(theta), block): b = slice(a, a + block) c = (weights[b] @ coef.reshape(n_s, -1)).reshape(-1, n, n) # coefficients at each point's s c = (c * np.exp(1j * np.outer(zeta[b], k_zeta))[:, :, None]).sum(axis=1) out[b] = (c * np.exp(1j * np.outer(theta[b], k_theta))).sum(axis=1).real return out
[docs] def read_trajectories(f, config): """Read all trajectories of one configuration of a trajectory file. The trajectories are put on a common step axis and embedded with ``embed_state``: ``z[i, k]`` is the state of trajectory ``i`` at step ``k``, that is, :math:`k` µs after its first sample. A lost particle stops early, so its rows after its last regular sample are NaN; its final sample, at the time it was lost, falls between steps and is not in ``z``. ``n_valid`` is the number of filled rows of each trajectory, and ``t_loss`` is the time each particle was lost [s], or NaN if it stays confined. Trajectories with ``n_valid == 0`` were lost before their first sample. Args: f (h5py.File): an open trajectory file. config (int or str): the row index in the file or the ``uuid`` of the configuration. Returns: dict: **z, n_valid, t_loss**, of shapes ``(n_trajectories, 1000, 5)``, ``(n_trajectories,)`` and ``(n_trajectories,)``. Raises: KeyError: if no configuration in the file has this ``uuid``. """ i = _config_index(f, config) c0, c1 = f["config_offsets"][i], f["config_offsets"][i + 1] offsets = f["traj_offsets"][c0:c1 + 1] block = f["traj_data"][offsets[0]:offsets[-1]] # columns: t, s, theta, phi, vpar starts, lengths = offsets[:-1] - offsets[0], np.diff(offsets) t_last = block[starts + lengths - 1, 0] lost = t_last < T_MAX - LOSS_TOL n_valid = lengths - lost # a lost orbit's final sample is off the grid k = np.arange(N_STEPS + 1) on_grid = k[None, :] < n_valid[:, None] rows = (starts[:, None] + k[None, :])[on_grid] z = np.full((len(lengths), N_STEPS + 1, 5), np.nan) z[on_grid] = embed_state(*block[rows, 1:].T) return {"z": z, "n_valid": n_valid, "t_loss": np.where(lost, t_last, np.nan)}
[docs] def trajectory_metrics(paths, predictions): r"""Score trajectory predictions with the metrics of the paper. For every trajectory, a model predicts the state 10, 100 and 999 µs after its first sample, and the time the particle is lost. Trajectories lost before their first sample are not scored. Over all predicted configurations, the function reports the nRMSE of the states at each lead time ``nrmse_10_microsec``, ``nrmse_100_microsec`` and ``nrmse_999_microsec`` (``nrmse``); the mean absolute error of the loss fraction ``loss_fraction_mae`` (``loss_fraction_mae``); the Wasserstein-1 distance of the confinement times ``confinement_time_w1_microsec`` (``confinement_time_w1``); and the number of configurations scored ``n_configurations``. Each nRMSE is followed by its breakdown by channel group, ``nrmse_<k>_microsec_<group>``; the nRMSE is the mean of the four groups. The groups are channels of the embedded state (see ``embed_state``): ``pol_x`` is :math:`\sqrt{s}\cos\theta`, ``pol_y`` is :math:`\sqrt{s}\sin\theta`, ``phi`` is the pair :math:`(\cos\varphi, \sin\varphi)`, and ``lambda`` is :math:`v_\parallel / v_\alpha`. As in the paper, a predicted particle is lost the first time its predicted :math:`s \ge 0.98`, and its predicted state is held at the loss state afterwards. Args: paths (list of str): the trajectory files holding the predicted configurations, usually ``data.list_files(data_dir, "trajectory", "test")``. predictions (dict): maps the ``uuid`` of each configuration to a tuple ``(z, t_loss)``, with one row per trajectory in the order of ``read_trajectories``: the predicted embedded states at the three lead times, shape ``(n_trajectories, 3, 5)``, and the predicted loss times [s], NaN for particles that stay confined, shape ``(n_trajectories,)``. Loss times count from the start of the simulation, as in the data. Returns: dict: **nrmse_10_microsec, nrmse_100_microsec, nrmse_999_microsec, loss_fraction_mae, confinement_time_w1_microsec, n_configurations**, the metrics above and the number of configurations scored, with the nRMSE breakdown after each nRMSE. Raises: KeyError: if a predicted configuration is not in the files. ValueError: if a prediction has the wrong shape, or contains inf or NaN states. """ z_pred, z_true, t_pred, t_true, in_files = [], [], [], [], set() for path in paths: with h5py.File(path, "r") as f: uuids = _read_rows(f["uuid"], slice(None)) in_files.update(uuids) for i, uuid in enumerate(uuids): if uuid in predictions: truth = read_trajectories(f, i) keep = truth["n_valid"] >= 1 # orbits lost before their first sample z, t_loss = _checked_prediction(*predictions[uuid], len(keep)) z_pred.append(z[keep]) t_pred.append(t_loss[keep]) z_true.append(truth["z"][keep][:, TABLE_STEPS]) t_true.append(truth["t_loss"][keep]) missing = set(predictions) - in_files if missing: raise KeyError(f"{len(missing)} predicted configurations are not in the files, e.g. {next(iter(missing))}") groups = nrmse(z_pred, z_true, by_group=True) total = np.mean(list(groups.values()), axis=0) out = {} for j, k in enumerate(TABLE_STEPS): out[f"nrmse_{k}_microsec"] = float(total[j]) for g, values in groups.items(): # its breakdown by channel group out[f"nrmse_{k}_microsec_{g}"] = float(values[j]) out["loss_fraction_mae"] = loss_fraction_mae(t_pred, t_true) out["confinement_time_w1_microsec"] = confinement_time_w1(t_pred, t_true) out["n_configurations"] = len(z_pred) return out
[docs] def nrmse(z_pred, z_true, by_group=False): r"""Compute the normalized root-mean-square error of predicted states, averaged over configurations. For the four channel groups :math:`g` of the embedded state, ``pol_x`` (:math:`\sqrt{s}\cos\theta`), ``pol_y`` (:math:`\sqrt{s}\sin\theta`), ``phi`` (:math:`\cos\varphi, \sin\varphi`) and ``lambda`` (:math:`v_\parallel / v_\alpha`), with frozen training-set RMS :math:`\sigma_g`, .. math:: \mathrm{nRMSE}(\tau) = \frac{1}{4} \sum_g \frac{1}{\sigma_g} \frac{1}{D} \sum_d \left( \frac{1}{n_{d,\tau}} \sum_i \left\|\hat z_{i,g}(\tau) - z_{i,g}(\tau)\right\|^2 \right)^{1/2}, where :math:`i` runs over the :math:`n_{d,\tau}` particles of configuration :math:`d` that are still confined at :math:`\tau`, and :math:`d` over the :math:`D` configurations with any. Args: z_pred (list of np.ndarray): per configuration, the predicted embedded states at the lead times, shape ``(n_d, n_tau, 5)``. z_true (list of np.ndarray): per configuration, the true states, of the same shapes; NaN where the particle is no longer confined. by_group (bool): if True, return the nRMSE of each channel group instead of their mean. Returns: np.ndarray or dict: the nRMSE at each lead time, shape ``(n_tau,)``. With ``by_group``, a dict that maps each group, **pol_x, pol_y, phi, lambda**, to its term :math:`\frac{1}{\sigma_g D} \sum_d (\dots)^{1/2}`, shape ``(n_tau,)``; their mean is the nRMSE. Raises: ValueError: if a predicted state contains NaN or inf. """ rmse = {g: [] for g in CHANNEL_GROUPS} for zp, zt in zip(z_pred, z_true): require_finite(z_pred=zp) valid = np.isfinite(zt).all(axis=-1) n_valid = valid.sum(axis=0) for g, (channels, _) in CHANNEL_GROUPS.items(): err2 = ((zp[..., channels] - zt[..., channels]) ** 2).sum(axis=-1) sse = np.where(valid, err2, 0).sum(axis=0) rmse[g].append(np.where(n_valid > 0, np.sqrt(sse / np.maximum(n_valid, 1)), np.nan)) per_group = {g: np.nanmean(rmse[g], axis=0) / sigma for g, (_, sigma) in CHANNEL_GROUPS.items()} return per_group if by_group else np.mean(list(per_group.values()), axis=0)
[docs] def loss_fraction_mae(t_loss_pred, t_loss_true): r"""Compute the mean absolute error of the fraction of particles lost within 1 ms, averaged over configurations. With :math:`f_d` the fraction of the particles of configuration :math:`d` lost by :math:`t = 1` ms, .. math:: \text{MAE} = \frac{1}{D} \sum_d \left|\hat f_d - f_d\right|. Args: t_loss_pred (list of np.ndarray): per configuration, the predicted loss times [s], NaN if confined, shape ``(n_d,)``. t_loss_true (list of np.ndarray): per configuration, the true loss times [s], of the same shapes. Returns: float: the MAE. Raises: ValueError: if a loss time is infinite. """ errors = [abs(_lost(tp).mean() - _lost(tt).mean()) for tp, tt in zip(t_loss_pred, t_loss_true)] return float(np.mean(errors))
[docs] def confinement_time_w1(t_loss_pred, t_loss_true): r"""Compute the Wasserstein-1 distance between predicted and true confinement times, averaged over configurations. With :math:`T` the confinement time of a particle, its loss time :math:`t_\text{loss}` if it is lost within 1 ms and 1 ms otherwise, and :math:`T_{d,(i)}` the :math:`i`-th smallest of the :math:`n_d` confinement times of configuration :math:`d`, .. math:: W_1 = \frac{1}{D} \sum_d \frac{1}{n_d} \sum_i \left|\hat T_{d,(i)} - T_{d,(i)}\right|. Args: t_loss_pred (list of np.ndarray): per configuration, the predicted loss times [s], NaN if confined, shape ``(n_d,)``. t_loss_true (list of np.ndarray): per configuration, the true loss times [s], of the same shapes. Returns: float: :math:`W_1` [µs]. Raises: ValueError: if a loss time is infinite. """ distances = [] for tp, tt in zip(t_loss_pred, t_loss_true): T_pred = np.where(_lost(tp), tp, T_MAX) * 1e6 T_true = np.where(_lost(tt), tt, T_MAX) * 1e6 distances.append(np.mean(np.abs(np.sort(T_pred) - np.sort(T_true)))) return float(np.mean(distances))
def _lost(t_loss): """Flag the particles lost within the 1 ms window; a loss at the last sample, t = 1 ms, counts. Args: t_loss (np.ndarray): loss times [s], NaN if confined. Returns: np.ndarray: boolean, the shape of t_loss. Raises: ValueError: if a loss time is infinite. """ t_loss = np.asarray(t_loss, dtype=float) require_finite(t_loss=t_loss[~np.isnan(t_loss)]) return t_loss <= T_MAX + DT / 2 def _checked_prediction(z, t_loss, n_orbits): """Check the shapes of a configuration's prediction and convert it to arrays. Args: z (array_like): predicted states, expected shape (n_orbits, 3, 5). t_loss (array_like): predicted loss times [s], expected shape (n_orbits,). n_orbits (int): the number of orbits of the configuration. Returns: tuple: (z, t_loss) as float np.ndarray. Raises: ValueError: if a shape is wrong. """ z, t_loss = np.asarray(z, dtype=float), np.asarray(t_loss, dtype=float) if z.shape != (n_orbits, len(TABLE_STEPS), 5) or t_loss.shape != (n_orbits,): raise ValueError(f"expected z of shape {(n_orbits, len(TABLE_STEPS), 5)} and t_loss of " f"shape {(n_orbits,)}, got {z.shape} and {t_loss.shape}") return z, t_loss