boss_bench

Source code for boss_bench.heat_flux

"""Heat-flux benchmark: predict where lost alpha particles strike the plasma boundary."""

import h5py
import numpy as np
from scipy.ndimage import map_coordinates
from scipy.spatial.distance import cdist

from ._checks import require_finite
from .confinement import E_ALPHA_MEV, S_INITS, is_lost
from .data import _read_rows

HF_RESOLUTIONS = (16, 32)
MIN_LOST_ED = 10
BLOCK_SIZE = 64  # configurations read from disk at once
PARTICLE_FIELDS = ("uuid", "nfp", "t_max", "s_init", "confinement_time", "zeta_end", "theta_end")
BOOZER_FIELDS = {"R": "R_boozer", "Z": "Z_boozer", "nu": "nu_boozer"}  # boundary geometry


[docs] def lost_particles(record, s0): r"""Compute the strike points ``(zeta, theta)`` and energies ``energy`` of the lost particles born on one surface. For the :math:`k` lost particles born on ``s0``, ``zeta`` and ``theta`` are the Boozer angles where they strike the boundary, in :math:`[0, 2\pi)` [rad], and ``energy`` is their energy [MeV], each of shape ``(k,)``. A particle lost at time :math:`t_\text{loss}` carries the energy :math:`E = 3.5\,\text{MeV}\; e^{-2 t_\text{loss} / t_\text{slowing}}`, where the slowing-down time is approximated by :math:`t_\text{slowing} \approx t_\text{max} = 0.1` s. ``n_launched`` is the number of particles born on ``s0``. Args: record (dict): the configuration's fields from ``data.load_record``, including ``confinement_time``, ``t_max``, ``s_init``, ``zeta_end`` and ``theta_end``. s0 (float): the birth surface. Returns: tuple: **zeta, theta, energy, n_launched**, the strike points, their energies, and the number of particles launched. Raises: ValueError: if the confinement times or the strike points contain NaN or inf. """ ct, t_max = record["confinement_time"], record["t_max"] born = np.isclose(record["s_init"], s0) lost = born & is_lost(ct, t_max) require_finite(zeta_end=record["zeta_end"][lost], theta_end=record["theta_end"][lost]) zeta = np.mod(record["zeta_end"][lost], 2 * np.pi) theta = np.mod(record["theta_end"][lost], 2 * np.pi) energy = E_ALPHA_MEV * np.exp(-2 * ct[lost] / t_max) return zeta, theta, energy, int(born.sum())
[docs] def binned_heat_flux(zeta, theta, energy, n_launched, record, resolution=32): r"""Compute the heat flux on the plasma boundary [MeV/m² per launched particle], binned over one field period. The strike points of all :math:`n_\text{fp}` field periods are first folded into one. With :math:`m` = ``resolution``, :math:`Q_{ij}` is the heat flux in the bin :math:`[\zeta_i, \zeta_{i+1}) \times [\theta_j, \theta_{j+1})`, where :math:`\zeta_i = 2\pi i / (m\, n_\text{fp})` and :math:`\theta_j = 2\pi j / m` for :math:`i, j = 0, \dots, m - 1`: .. math:: Q_{ij} = \frac{1}{N\, n_\text{fp}\, A_{ij}} \sum_k E_k, where the sum runs over the strike points in the bin, with energies :math:`E_k`, :math:`A_{ij}` is the boundary area of the bin, and :math:`N` is the number of particles launched. To estimate the absolute heat flux per unit time [W/m²], multiply :math:`Q` by a birth rate [1/s] and by :math:`1.602 \times 10^{-13}` J/MeV. Args: zeta (np.ndarray): Boozer toroidal angles of the strike points [rad], shape ``(k,)``. theta (np.ndarray): Boozer poloidal angles of the strike points [rad], shape ``(k,)``. energy (np.ndarray): energies of the strike points [MeV], shape ``(k,)``. n_launched (int): the number of particles launched. record (dict): the configuration's fields from ``data.load_record``, including ``R_boozer``, ``Z_boozer``, ``nu_boozer``, ``s1d`` and ``nfp``. resolution (int): the number of bins per angle. Returns: np.ndarray: **Q**, the heat flux :math:`Q_{ij}` [MeV/m² per launched particle], shape ``(resolution, resolution)``. Raises: ValueError: if an input contains NaN or inf. """ require_finite(zeta=zeta, theta=theta, energy=energy) nfp = int(record["nfp"]) R, Z, nu = (_boundary_slice(record, name) for name in BOOZER_FIELDS.values()) area = _period_bin_areas(R, Z, nu, nfp, resolution) return _heat_flux_map(zeta, theta, energy, n_launched, nfp, area)
[docs] def heat_flux_metrics(paths, predictions): """Score heat-flux predictions with the metrics of the paper. A sample is the point cloud of strike points of the lost particles born on one surface ``s0`` of one configuration, so each configuration gives three samples, one per birth surface. Each predicted sample is compared with the true one by the heat-flux error on 16 × 16 and 32 × 32 grids (``heat_flux_error``), the energy distance between the two point clouds (``energy_distance``), and the absolute error of the loss fraction. Over all samples, the function reports the median energy distance ``ED_median`` (over samples with at least 10 lost particles); the median and mean heat-flux errors ``HF_16_median``, ``HF_16_mean``, ``HF_32_median`` and ``HF_32_mean``; the median and mean absolute error of the loss fraction ``loss_fraction_median_abs_error`` and ``loss_fraction_mean_abs_error``; and the number of samples scored ``n_samples``. Only the fields it needs are read from disk. Args: paths (list of str): the confinement files holding the predicted samples, usually ``data.list_files(data_dir, "confinement", "test")``. predictions (dict): maps ``(uuid, s0)`` of each sample to the tuple ``(zeta, theta, energy, loss_fraction)``: the Boozer angles [rad] and energies [MeV] of any number of predicted strike points, arrays of shape ``(k,)``, and the predicted loss fraction, a float. Returns: dict: **ED_median, HF_16_median, HF_16_mean, HF_32_median, HF_32_mean, loss_fraction_median_abs_error, loss_fraction_mean_abs_error, n_samples**, the metrics above and the number of samples scored. Raises: KeyError: if a predicted sample is not in the files. ValueError: if a prediction contains NaN or inf, or has no strike points for a sample with at least 10 lost particles. """ results, scored = [], set() for path in paths: with h5py.File(path, "r") as f: for record in _read_configurations(f): for s0 in S_INITS: key = (record["uuid"], s0) if key in predictions: results.append(_sample_metrics(*predictions[key], _target(record, s0))) scored.add(key) missing = set(predictions) - scored if missing: raise KeyError(f"{len(missing)} predicted samples are not in the files, e.g. {next(iter(missing))}") return {**_summarize(results), "n_samples": len(results)}
[docs] def heat_flux_error(prediction, record, s0, resolution=32): r"""Compute the heat-flux error of a prediction for one sample. With :math:`Q` and :math:`\hat Q` the true and predicted heat flux from ``binned_heat_flux`` on :math:`m \times m` bins of areas :math:`A_b`, relative to a uniform loss of every particle at the birth energy :math:`E_\alpha = 3.5` MeV over the boundary area :math:`S`, .. math:: \mathrm{HF}_m^2 = \frac{n_\text{fp} \sum_b A_b \left(\hat Q_b - Q_b\right)^2}{E_\alpha^2 / S}. The predicted strike points are scaled so that their total is the predicted loss fraction. Args: prediction (tuple): the tuple ``(zeta, theta, energy, loss_fraction)``: the Boozer angles [rad] and energies [MeV] of any number of predicted strike points, arrays of shape ``(k,)``, and the predicted loss fraction, a float. record (dict): the configuration's fields from ``data.load_record`` on a confinement file. s0 (float): the birth surface. resolution (int): the number of bins per angle, :math:`m`. Returns: float: :math:`\mathrm{HF}_m`. Raises: ValueError: if a prediction contains NaN or inf. """ zeta, theta, energy, loss_fraction = prediction require_finite(zeta=zeta, theta=theta, energy=energy, loss_fraction=loss_fraction) return _heat_flux_error(zeta, theta, energy, loss_fraction, _target(_with_boundary(record), s0), resolution)
[docs] def energy_distance(prediction, record, s0): r"""Compute the energy distance between predicted and true strike points, for one sample. With :math:`X` and :math:`Y` the predicted and true strike points as positions on the boundary, .. math:: \mathrm{ED} = \left| \frac{2\,\mathbb{E}\|X - Y\| - \mathbb{E}\|X - X'\| - \mathbb{E}\|Y - Y'\|}{\mathbb{E}\|Y - Y'\|} \right|^{1/2}, where the self terms average over pairs of distinct points. It is defined for samples with at least 10 lost particles. Args: prediction (tuple): ``(zeta, theta)``, the Boozer angles [rad] of any number of predicted strike points, arrays of shape ``(k,)``. Further entries, such as the energies and loss fraction of the tuple passed to ``heat_flux_error``, are ignored. record (dict): the configuration's fields from ``data.load_record`` on a confinement file. s0 (float): the birth surface. Returns: float: :math:`\mathrm{ED}`, or NaN if fewer than 10 particles born on ``s0`` are lost. Raises: ValueError: if a prediction contains NaN or inf, or has no strike points while at least 10 particles are lost. """ zeta, theta = prediction[:2] # anything after the angles is ignored require_finite(zeta=zeta, theta=theta) return _energy_distance_of_sample(zeta, theta, _target(_with_boundary(record), s0))
def _sample_metrics(zeta, theta, energy, loss_fraction, target): """Score one predicted sample against its target. Args: zeta (np.ndarray): Boozer toroidal angles of the predicted strike points [rad]. theta (np.ndarray): Boozer poloidal angles of the predicted strike points [rad]. energy (np.ndarray): energies of the predicted strike points [MeV]. loss_fraction (float): the predicted loss fraction. target (dict): the true sample, from ``_target``. Returns: dict: "HF_16" and "HF_32"; "ED" (NaN with fewer than 10 true lost particles); and "loss_fraction_error". Raises: ValueError: if a prediction contains NaN or inf, or if the target has an energy distance but no strike points are predicted. """ require_finite(zeta=zeta, theta=theta, energy=energy, loss_fraction=loss_fraction) out = {"loss_fraction_error": abs(loss_fraction - target["loss_fraction"])} for m in HF_RESOLUTIONS: out[f"HF_{m}"] = _heat_flux_error(zeta, theta, energy, loss_fraction, target, m) out["ED"] = _energy_distance_of_sample(zeta, theta, target) return out def _energy_distance_of_sample(zeta, theta, target): """The energy distance of predicted strike points from a target's (see ``energy_distance``). Args: zeta, theta (np.ndarray): Boozer angles of the predicted strike points [rad]. target (dict): the true sample, from ``_target``. Returns: float: ED, or NaN if the target has fewer than MIN_LOST_ED lost particles. Raises: ValueError: if the target has an energy distance but no strike points are predicted. """ if len(target["zeta"]) < MIN_LOST_ED: return np.nan if len(zeta) == 0: raise ValueError(f"the target has {len(target['zeta'])} lost particles; predict at least one strike point") x_pred = _boundary_points(zeta, theta, target) x_true = _boundary_points(target["zeta"], target["theta"], target) return _energy_distance(x_pred, x_true) def _read_configurations(f): """Read the fields needed for scoring, a block of configurations at a time. Of the Boozer fields, only the boundary surface is read. Args: f (h5py.File): an open confinement file. Yields: dict: one configuration's fields in PARTICLE_FIELDS, and its boundary geometry "R", "Z", "nu" on the (zeta, theta) grid of one field period. """ n_phi, n_theta = f["phi1d"].shape[1], f["theta1d"].shape[1] n_configs = len(f["uuid"]) for start in range(0, n_configs, BLOCK_SIZE): rows = slice(start, min(start + BLOCK_SIZE, n_configs)) block = {name: _read_rows(f[name], rows) for name in PARTICLE_FIELDS} for key, name in BOOZER_FIELDS.items(): boundary = f[name][rows, -n_phi * n_theta:] # the last surface, s = 1 block[key] = boundary.reshape(-1, n_phi, n_theta) for j in range(rows.stop - rows.start): yield {name: values[j] for name, values in block.items()} def _with_boundary(record): """Add the boundary geometry "R", "Z", "nu" to a record read with ``data.load_record``. Args: record (dict): the configuration's fields, including the Boozer fields and ``s1d``. Returns: dict: the record, with the boundary geometry on the (zeta, theta) grid of one field period. """ return {**record, **{key: _boundary_slice(record, name) for key, name in BOOZER_FIELDS.items()}} def _target(record, s0): """The true sample of one configuration and birth surface, for scoring. Args: record (dict): the fields in PARTICLE_FIELDS, and the boundary geometry "R", "Z", "nu". s0 (float): the birth surface. Returns: dict: the "s_init", "n_launched", true "loss_fraction", the lost particles' "zeta", "theta" and "energy", and the geometry "R", "Z", "nu", "nfp". """ zeta, theta, energy, n_launched = lost_particles(record, s0) return {"s_init": s0, "n_launched": n_launched, "loss_fraction": len(zeta) / n_launched, "zeta": zeta, "theta": theta, "energy": energy, "R": record["R"], "Z": record["Z"], "nu": record["nu"], "nfp": int(record["nfp"])} def _summarize(results): """Aggregate per-sample scores into the reported numbers. NaN entries are skipped. Args: results (list of dict): ``_sample_metrics`` outputs, one per sample. Returns: dict: the median ED, the median and mean HF at each resolution, and the median and mean absolute loss-fraction error (NaN where there are no values). """ out = {"ED_median": _statistic(np.median, results, "ED")} for m in HF_RESOLUTIONS: out[f"HF_{m}_median"] = _statistic(np.median, results, f"HF_{m}") out[f"HF_{m}_mean"] = _statistic(np.mean, results, f"HF_{m}") out["loss_fraction_median_abs_error"] = _statistic(np.median, results, "loss_fraction_error") out["loss_fraction_mean_abs_error"] = _statistic(np.mean, results, "loss_fraction_error") return out def _statistic(f, results, key): """Apply a statistic to the finite values of one score over all samples. Args: f (callable): the statistic, e.g. np.median. results (list of dict): ``_sample_metrics`` outputs. key (str): the score. Returns: float: the statistic, or NaN if no sample has a finite value. """ a = np.array([r[key] for r in results], dtype=float) a = a[np.isfinite(a)] return float(f(a)) if a.size else np.nan def _heat_flux_error(zeta, theta, energy, loss_fraction, target, m): """The heat-flux error HF_m of one predicted sample (see ``heat_flux_error``). Args: zeta, theta, energy (np.ndarray): the predicted strike points and energies. loss_fraction (float): the predicted loss fraction. target (dict): the true sample, from ``_target``. m (int): number of bins per angle. Returns: float: HF_m. """ nfp, n = target["nfp"], target["n_launched"] area = _period_bin_areas(target["R"], target["Z"], target["nu"], nfp, m) q_true = _heat_flux_map(target["zeta"], target["theta"], target["energy"], n, nfp, area) scale = loss_fraction * n / len(energy) if len(energy) else 0.0 # total = predicted loss fraction q_pred = scale * _heat_flux_map(zeta, theta, energy, n, nfp, area) err2 = nfp * (area * (q_pred - q_true) ** 2).sum() # int (Q_pred - Q_true)^2 dA ref2 = E_ALPHA_MEV ** 2 / (nfp * area.sum()) # int Q_ref^2 dA return float(np.sqrt(err2 / ref2)) def _heat_flux_map(zeta, theta, energy, n_launched, nfp, area): """Bin strike-point energies over one field period and divide by the bin areas. Args: zeta, theta (np.ndarray): Boozer angles [rad], any range; zeta is folded into one field period. energy (np.ndarray): energy of each strike point [MeV]. n_launched (int): number of particles launched. nfp (int): number of field periods. area (np.ndarray): bin areas [m^2], shape (m, m), from ``_period_bin_areas``. Returns: np.ndarray: the heat flux [MeV / m^2 per launched particle], shape (m, m). """ m = area.shape[0] period = 2 * np.pi / nfp energy_sum = np.histogram2d(np.mod(zeta, period), np.mod(theta, 2 * np.pi), bins=m, range=[[0, period], [0, 2 * np.pi]], weights=energy)[0] return energy_sum / (n_launched * nfp * area) def _energy_distance(x, y): """Normalized energy distance between two point clouds (see ``energy_distance``). Args: x (np.ndarray): predicted points, shape (n, 3). y (np.ndarray): true points, shape (k, 3). Returns: float: ED. """ n, k = len(x), len(y) exy = cdist(x, y).mean() exx = cdist(x, x).sum() / max(n * (n - 1), 1) eyy = cdist(y, y).sum() / max(k * (k - 1), 1) return float(np.sqrt(abs((2 * exy - exx - eyy) / eyy))) def _boundary_points(zeta, theta, target): """Map Boozer angles to Cartesian positions on the boundary. R, Z and nu are interpolated with cubic splines, and the cylindrical angle is zeta - nu. Args: zeta, theta (np.ndarray): Boozer angles [rad]. target (dict): a sample from ``_target``, for the boundary geometry. Returns: np.ndarray: positions [m], shape (n, 3). """ R, Z, nu, nfp = target["R"], target["Z"], target["nu"], target["nfp"] n_zeta, n_theta = R.shape period = 2 * np.pi / nfp coords = np.stack([np.mod(zeta, period) / period * n_zeta, np.mod(theta, 2 * np.pi) / (2 * np.pi) * n_theta]) R_i, Z_i, nu_i = (map_coordinates(a, coords, order=3, mode="grid-wrap") for a in (R, Z, nu)) phi = np.asarray(zeta, dtype=float) - nu_i return np.stack([R_i * np.cos(phi), R_i * np.sin(phi), Z_i], axis=1) def _period_bin_areas(R, Z, nu, nfp, m, sub=8): """Boundary area of each bin of an m x m (zeta, theta) grid over one field period. Uses a midpoint rule on sub x sub points per bin, interpolating the area element bilinearly. Args: R, Z, nu (np.ndarray): boundary geometry on the (zeta, theta) grid of one field period. nfp (int): number of field periods. m (int): number of bins per angle. sub (int): midpoint-rule points per bin and angle. Returns: np.ndarray: bin areas [m^2], shape (m, m). """ dA = _area_element(R, Z, nu, nfp) fz = (np.arange(m * sub) + 0.5) / (m * sub) * dA.shape[0] ft = (np.arange(m * sub) + 0.5) / (m * sub) * dA.shape[1] vals = map_coordinates(dA, np.stack(np.meshgrid(fz, ft, indexing="ij")), order=1, mode="grid-wrap") cell = vals.reshape(m, sub, m, sub).mean(axis=(1, 3)) return cell * (2 * np.pi / nfp / m) * (2 * np.pi / m) def _area_element(R, Z, nu, nfp): """Area element |dr/dtheta x dr/dzeta| of the boundary, with cylindrical angle zeta - nu. Args: R, Z, nu (np.ndarray): boundary geometry on the (zeta, theta) grid of one field period. nfp (int): number of field periods. Returns: np.ndarray: the area element [m^2 / rad^2], the shape of R. """ R_t, R_z = _d_dtheta(R), _d_dzeta(R, nfp) Z_t, Z_z = _d_dtheta(Z), _d_dzeta(Z, nfp) Rphi_t = -R * _d_dtheta(nu) Rphi_z = R * (1 - _d_dzeta(nu, nfp)) c_R = Rphi_t * Z_z - Z_t * Rphi_z c_phi = Z_t * R_z - R_t * Z_z c_Z = R_t * Rphi_z - Rphi_t * R_z return np.sqrt(c_R ** 2 + c_phi ** 2 + c_Z ** 2) def _d_dtheta(a): """Spectral derivative along theta, which has period 2 pi. Args: a (np.ndarray): values on the (zeta, theta) grid. Returns: np.ndarray: da/dtheta on the same grid. """ k = np.fft.fftfreq(a.shape[1]) * a.shape[1] return np.real(np.fft.ifft(1j * k[None, :] * np.fft.fft(a, axis=1), axis=1)) def _d_dzeta(a, nfp): """Spectral derivative along zeta, which has period 2 pi / nfp on the grid. Args: a (np.ndarray): values on the (zeta, theta) grid. nfp (int): number of field periods. Returns: np.ndarray: da/dzeta on the same grid. """ k = np.fft.fftfreq(a.shape[0]) * a.shape[0] * nfp return np.real(np.fft.ifft(1j * k[:, None] * np.fft.fft(a, axis=0), axis=0)) def _boundary_slice(record, name): """Read the boundary (s = 1) slice of a Boozer field. Args: record (dict): the configuration's fields, from ``data.load_record``. name (str): a field stored flattened over (s, zeta, theta), e.g. "R_boozer". Returns: np.ndarray: the field on the boundary, shape (n_zeta, n_theta), over one field period. """ a = np.asarray(record[name], dtype=float) n = int(round(np.sqrt(a.size / len(record["s1d"])))) return a[-n * n:].reshape(n, n)