|
| 1 | +#!/usr/bin/env python |
| 2 | +r""" |
| 3 | +This example script uses the GPMO |
| 4 | +greedy algorithm for solving permanent |
| 5 | +magnet optimization on the MUSE grid. This |
| 6 | +algorithm is described in the following paper: |
| 7 | + A. A. Kaptanoglu, R. Conlin, and M. Landreman, |
| 8 | + Greedy permanent magnet optimization, |
| 9 | + Nuclear Fusion 63, 036016 (2023) |
| 10 | +
|
| 11 | +The script should be run as: |
| 12 | + mpirun -n 1 python permanent_magnet_MUSE.py |
| 13 | +on a cluster machine but |
| 14 | + python permanent_magnet_MUSE.py |
| 15 | +is sufficient on other machines. Note that the code is |
| 16 | +parallelized via OpenMP and XSIMD, so will run substantially |
| 17 | +faster on multi-core machines (make sure that all the cores |
| 18 | +are available to OpenMP, e.g. through setting OMP_NUM_THREADS). |
| 19 | +
|
| 20 | +For high-resolution and more realistic designs, please see the script files at |
| 21 | +https://github.com/akaptano/simsopt_permanent_magnet_advanced_scripts.git |
| 22 | +""" |
| 23 | + |
| 24 | +import os |
| 25 | +import pickle |
| 26 | +import time |
| 27 | +from pathlib import Path |
| 28 | + |
| 29 | +import numpy as np |
| 30 | +from matplotlib import pyplot as plt |
| 31 | + |
| 32 | +from simsopt.field import BiotSavart, DipoleField |
| 33 | +from simsopt.geo import PermanentMagnetGrid, SurfaceRZFourier |
| 34 | +from simsopt.objectives import SquaredFlux |
| 35 | +from simsopt.solve import GPMO |
| 36 | +from simsopt.util import FocusData, discretize_polarizations, polarization_axes |
| 37 | +from simsopt.util.permanent_magnet_helper_functions import * |
| 38 | + |
| 39 | +t_start = time.time() |
| 40 | + |
| 41 | +# Set some parameters -- if doing CI, lower the resolution |
| 42 | +ci = "CI" in os.environ and os.environ['CI'].lower() in ['1', 'true'] |
| 43 | +if ci: |
| 44 | + nphi = 2 |
| 45 | + nIter_max = 100 |
| 46 | + nBacktracking = 50 |
| 47 | + max_nMagnets = 20 |
| 48 | + downsample = 100 # downsample the FAMUS grid of magnets by this factor |
| 49 | +else: |
| 50 | + nphi = 16 # >= 64 for high-resolution runs |
| 51 | + nIter_max = 10000 |
| 52 | + nBacktracking = 200 |
| 53 | + max_nMagnets = 1000 |
| 54 | + downsample = 1 |
| 55 | + |
| 56 | +ntheta = nphi # same as above |
| 57 | +dr = 0.01 # Radial extent in meters of the cylindrical permanent magnet bricks |
| 58 | +input_name = 'input.muse' |
| 59 | + |
| 60 | +# Read in the plasma equilibrium file |
| 61 | +TEST_DIR = (Path(__file__).parent / ".." / ".." / "tests" / "test_files").resolve() |
| 62 | +famus_filename = TEST_DIR / 'zot80.focus' |
| 63 | +surface_filename = TEST_DIR / input_name |
| 64 | +s = SurfaceRZFourier.from_focus(surface_filename, range="half period", nphi=nphi, ntheta=ntheta) |
| 65 | +s_inner = SurfaceRZFourier.from_focus(surface_filename, range="half period", nphi=nphi, ntheta=ntheta) |
| 66 | +s_outer = SurfaceRZFourier.from_focus(surface_filename, range="half period", nphi=nphi, ntheta=ntheta) |
| 67 | + |
| 68 | +# Make the output directory -- warning, saved data can get big! |
| 69 | +# On NERSC, recommended to change this directory to point to SCRATCH! |
| 70 | +out_dir = Path("output_permanent_magnet_GPMO_MUSE") |
| 71 | +out_dir.mkdir(parents=True, exist_ok=True) |
| 72 | + |
| 73 | +# initialize the coils |
| 74 | +base_curves, curves, coils = initialize_coils('muse_famus', TEST_DIR, s, out_dir) |
| 75 | + |
| 76 | +# Set up BiotSavart fields |
| 77 | +bs = BiotSavart(coils) |
| 78 | + |
| 79 | +# Calculate average, approximate on-axis B field strength |
| 80 | +calculate_on_axis_B(bs, s) |
| 81 | + |
| 82 | +# Make higher resolution surface for plotting Bnormal |
| 83 | +qphi = 2 * nphi |
| 84 | +quadpoints_phi = np.linspace(0, 1, qphi, endpoint=True) |
| 85 | +quadpoints_theta = np.linspace(0, 1, ntheta, endpoint=True) |
| 86 | +s_plot = SurfaceRZFourier.from_focus( |
| 87 | + surface_filename, |
| 88 | + quadpoints_phi=quadpoints_phi, |
| 89 | + quadpoints_theta=quadpoints_theta |
| 90 | +) |
| 91 | + |
| 92 | +# Plot initial Bnormal on plasma surface from un-optimized BiotSavart coils |
| 93 | +make_Bnormal_plots(bs, s_plot, out_dir, "biot_savart_initial") |
| 94 | + |
| 95 | +# Set up correct Bnormal from TF coils |
| 96 | +bs.set_points(s.gamma().reshape((-1, 3))) |
| 97 | +Bnormal = np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2) |
| 98 | + |
| 99 | +# Load a downsampled version of the magnet grid from a FAMUS file |
| 100 | +mag_data = FocusData(famus_filename, downsample=downsample) |
| 101 | + |
| 102 | +# Set the allowable orientations of the magnets to be face-aligned |
| 103 | +# i.e. the local x, y, or z directions |
| 104 | +pol_axes = np.zeros((0, 3)) |
| 105 | +pol_type = np.zeros(0, dtype=int) |
| 106 | +pol_axes_f, pol_type_f = polarization_axes(['face']) |
| 107 | +ntype_f = int(len(pol_type_f)/2) |
| 108 | +pol_axes_f = pol_axes_f[:ntype_f, :] |
| 109 | +pol_type_f = pol_type_f[:ntype_f] |
| 110 | +pol_axes = np.concatenate((pol_axes, pol_axes_f), axis=0) |
| 111 | +pol_type = np.concatenate((pol_type, pol_type_f)) |
| 112 | + |
| 113 | +# Optionally add additional types of allowed orientations |
| 114 | +PM4Stell_orientations = False |
| 115 | +if PM4Stell_orientations: |
| 116 | + pol_axes_fe_ftri, pol_type_fe_ftri = polarization_axes(['fe_ftri']) |
| 117 | + ntype_fe_ftri = int(len(pol_type_fe_ftri)/2) |
| 118 | + pol_axes_fe_ftri = pol_axes_fe_ftri[:ntype_fe_ftri, :] |
| 119 | + pol_type_fe_ftri = pol_type_fe_ftri[:ntype_fe_ftri] + 1 |
| 120 | + pol_axes = np.concatenate((pol_axes, pol_axes_fe_ftri), axis=0) |
| 121 | + pol_type = np.concatenate((pol_type, pol_type_fe_ftri)) |
| 122 | + pol_axes_fc_ftri, pol_type_fc_ftri = polarization_axes(['fc_ftri']) |
| 123 | + ntype_fc_ftri = int(len(pol_type_fc_ftri)/2) |
| 124 | + pol_axes_fc_ftri = pol_axes_fc_ftri[:ntype_fc_ftri, :] |
| 125 | + pol_type_fc_ftri = pol_type_fc_ftri[:ntype_fc_ftri] + 2 |
| 126 | + pol_axes = np.concatenate((pol_axes, pol_axes_fc_ftri), axis=0) |
| 127 | + pol_type = np.concatenate((pol_type, pol_type_fc_ftri)) |
| 128 | + |
| 129 | +ophi = np.arctan2(mag_data.oy, mag_data.ox) |
| 130 | +discretize_polarizations(mag_data, ophi, pol_axes, pol_type) |
| 131 | +pol_vectors = np.zeros((mag_data.nMagnets, len(pol_type), 3)) |
| 132 | +pol_vectors[:, :, 0] = mag_data.pol_x |
| 133 | +pol_vectors[:, :, 1] = mag_data.pol_y |
| 134 | +pol_vectors[:, :, 2] = mag_data.pol_z |
| 135 | +print('pol_vectors_shape = ', pol_vectors.shape) |
| 136 | + |
| 137 | +# pol_vectors is only used for the greedy algorithms with cartesian coordinate_flag |
| 138 | +# which is the default, so no need to specify it here. |
| 139 | +kwargs = {"pol_vectors": pol_vectors, "downsample": downsample, "dr": dr} |
| 140 | + |
| 141 | +# Finally, initialize the permanent magnet class |
| 142 | +pm_opt = PermanentMagnetGrid.geo_setup_from_famus(s, Bnormal, famus_filename, **kwargs) |
| 143 | + |
| 144 | +print('Number of available dipoles = ', pm_opt.ndipoles) |
| 145 | + |
| 146 | +# Set some hyperparameters for the optimization |
| 147 | +algorithm = 'ArbVec_backtracking' # Algorithm to use |
| 148 | +nAdjacent = 1 # How many magnets to consider "adjacent" to one another |
| 149 | +nHistory = 20 # How often to save the algorithm progress |
| 150 | +thresh_angle = np.pi # The angle between two "adjacent" dipoles such that they should be removed |
| 151 | +kwargs = initialize_default_kwargs('GPMO') |
| 152 | +kwargs['K'] = nIter_max # Maximum number of GPMO iterations to run |
| 153 | +kwargs['nhistory'] = nHistory |
| 154 | +if algorithm == 'backtracking' or algorithm == 'ArbVec_backtracking': |
| 155 | + kwargs['backtracking'] = nBacktracking # How often to perform the backtrackinig |
| 156 | + kwargs['Nadjacent'] = nAdjacent |
| 157 | + kwargs['dipole_grid_xyz'] = np.ascontiguousarray(pm_opt.dipole_grid_xyz) |
| 158 | + kwargs['max_nMagnets'] = max_nMagnets |
| 159 | + if algorithm == 'ArbVec_backtracking': |
| 160 | + kwargs['thresh_angle'] = thresh_angle |
| 161 | + |
| 162 | +# Optimize the permanent magnets greedily |
| 163 | +t1 = time.time() |
| 164 | +R2_history, Bn_history, m_history = GPMO(pm_opt, algorithm, **kwargs) |
| 165 | +t2 = time.time() |
| 166 | +print('GPMO took t = ', t2 - t1, ' s') |
| 167 | + |
| 168 | +# plot the MSE history |
| 169 | +iterations = np.linspace(0, kwargs['max_nMagnets'], len(R2_history), endpoint=False) |
| 170 | +plt.figure() |
| 171 | +plt.semilogy(iterations, R2_history, label=r'$f_B$') |
| 172 | +plt.semilogy(iterations, Bn_history, label=r'$<|Bn|>$') |
| 173 | +plt.grid(True) |
| 174 | +plt.xlabel('K') |
| 175 | +plt.ylabel('Metric values') |
| 176 | +plt.legend() |
| 177 | +plt.savefig(out_dir / 'GPMO_MSE_history.png') |
| 178 | + |
| 179 | +# Set final m to the minimum achieved during the optimization |
| 180 | +min_ind = np.argmin(R2_history) |
| 181 | +pm_opt.m = np.ravel(m_history[:, :, min_ind]) |
| 182 | + |
| 183 | +# Print effective permanent magnet volume |
| 184 | +B_max = 1.465 |
| 185 | +mu0 = 4 * np.pi * 1e-7 |
| 186 | +M_max = B_max / mu0 |
| 187 | +dipoles = pm_opt.m.reshape(pm_opt.ndipoles, 3) |
| 188 | +print('Volume of permanent magnets is = ', np.sum(np.sqrt(np.sum(dipoles ** 2, axis=-1))) / M_max) |
| 189 | +print('sum(|m_i|)', np.sum(np.sqrt(np.sum(dipoles ** 2, axis=-1)))) |
| 190 | + |
| 191 | +save_plots = False |
| 192 | +if save_plots: |
| 193 | + # Save the MSE history and history of the m vectors |
| 194 | + np.savetxt( |
| 195 | + out_dir / f"mhistory_K{kwargs['K']}_nphi{nphi}_ntheta{ntheta}.txt", |
| 196 | + m_history.reshape(pm_opt.ndipoles * 3, kwargs['nhistory'] + 1) |
| 197 | + ) |
| 198 | + np.savetxt( |
| 199 | + out_dir / f"R2history_K{kwargs['K']}_nphi{nphi}_ntheta{ntheta}.txt", |
| 200 | + R2_history |
| 201 | + ) |
| 202 | + # Plot the SIMSOPT GPMO solution |
| 203 | + bs.set_points(s_plot.gamma().reshape((-1, 3))) |
| 204 | + Bnormal = np.sum(bs.B().reshape((qphi, ntheta, 3)) * s_plot.unitnormal(), axis=2) |
| 205 | + make_Bnormal_plots(bs, s_plot, out_dir, "biot_savart_optimized") |
| 206 | + |
| 207 | + # Look through the solutions as function of K and make plots |
| 208 | + for k in range(0, kwargs["nhistory"] + 1, 50): |
| 209 | + mk = m_history[:, :, k].reshape(pm_opt.ndipoles * 3) |
| 210 | + b_dipole = DipoleField( |
| 211 | + pm_opt.dipole_grid_xyz, |
| 212 | + mk, |
| 213 | + nfp=s.nfp, |
| 214 | + coordinate_flag=pm_opt.coordinate_flag, |
| 215 | + m_maxima=pm_opt.m_maxima, |
| 216 | + ) |
| 217 | + b_dipole.set_points(s_plot.gamma().reshape((-1, 3))) |
| 218 | + K_save = int(kwargs['K'] / kwargs['nhistory'] * k) |
| 219 | + b_dipole._toVTK(out_dir / f"Dipole_Fields_K{K_save}_nphi{nphi}_ntheta{ntheta}") |
| 220 | + print("Total fB = ", 0.5 * np.sum((pm_opt.A_obj @ mk - pm_opt.b_obj) ** 2)) |
| 221 | + Bnormal_dipoles = np.sum(b_dipole.B().reshape((qphi, ntheta, 3)) * s_plot.unitnormal(), axis=-1) |
| 222 | + Bnormal_total = Bnormal + Bnormal_dipoles |
| 223 | + |
| 224 | + # For plotting Bn on the full torus surface at the end with just the dipole fields |
| 225 | + make_Bnormal_plots(b_dipole, s_plot, out_dir, "only_m_optimized_K{K_save}_nphi{nphi}_ntheta{ntheta}") |
| 226 | + pointData = {"B_N": Bnormal_total[:, :, None]} |
| 227 | + s_plot.to_vtk(out_dir / "m_optimized_K{K_save}_nphi{nphi}_ntheta{ntheta}", extra_data=pointData) |
| 228 | + |
| 229 | + # write solution to FAMUS-type file |
| 230 | + pm_opt.write_to_famus(out_dir) |
| 231 | + |
| 232 | +# Compute metrics with permanent magnet results |
| 233 | +dipoles_m = pm_opt.m.reshape(pm_opt.ndipoles, 3) |
| 234 | +num_nonzero = np.count_nonzero(np.sum(dipoles_m ** 2, axis=-1)) / pm_opt.ndipoles * 100 |
| 235 | +print("Number of possible dipoles = ", pm_opt.ndipoles) |
| 236 | +print("% of dipoles that are nonzero = ", num_nonzero) |
| 237 | + |
| 238 | +# Print optimized f_B and other metrics |
| 239 | +### Note this will only agree with the optimization in the high-resolution |
| 240 | +### limit where nphi ~ ntheta >= 64! |
| 241 | +b_dipole = DipoleField( |
| 242 | + pm_opt.dipole_grid_xyz, |
| 243 | + pm_opt.m, |
| 244 | + nfp=s.nfp, |
| 245 | + coordinate_flag=pm_opt.coordinate_flag, |
| 246 | + m_maxima=pm_opt.m_maxima, |
| 247 | +) |
| 248 | +b_dipole.set_points(s_plot.gamma().reshape((-1, 3))) |
| 249 | +bs.set_points(s_plot.gamma().reshape((-1, 3))) |
| 250 | +Bnormal = np.sum(bs.B().reshape((qphi, ntheta, 3)) * s_plot.unitnormal(), axis=2) |
| 251 | +f_B_sf = SquaredFlux(s_plot, b_dipole, -Bnormal).J() |
| 252 | +print('f_B = ', f_B_sf) |
| 253 | +total_volume = np.sum(np.sqrt(np.sum(pm_opt.m.reshape(pm_opt.ndipoles, 3) ** 2, axis=-1))) * s.nfp * 2 * mu0 / B_max |
| 254 | +print('Total volume = ', total_volume) |
| 255 | + |
| 256 | +# Optionally make a QFM and pass it to VMEC |
| 257 | +# This is worthless unless plasma |
| 258 | +# surface is at least 64 x 64 resolution. |
| 259 | +vmec_flag = False |
| 260 | +if vmec_flag: |
| 261 | + from mpi4py import MPI |
| 262 | + from simsopt.mhd.vmec import Vmec |
| 263 | + from simsopt.util.mpi import MpiPartition |
| 264 | + mpi = MpiPartition(ngroups=1) |
| 265 | + |
| 266 | + # Make the QFM surfaces |
| 267 | + t1 = time.time() |
| 268 | + Bfield = bs + b_dipole |
| 269 | + Bfield.set_points(s_plot.gamma().reshape((-1, 3))) |
| 270 | + qfm_surf = make_qfm(s_plot, Bfield) |
| 271 | + qfm_surf = qfm_surf.surface |
| 272 | + t2 = time.time() |
| 273 | + print("Making the QFM surface took ", t2 - t1, " s") |
| 274 | + |
| 275 | + # Run VMEC with new QFM surface |
| 276 | + t1 = time.time() |
| 277 | + |
| 278 | + ### Always use the QA VMEC file and just change the boundary |
| 279 | + vmec_input = "../../tests/test_files/input.LandremanPaul2021_QA" |
| 280 | + equil = Vmec(vmec_input, mpi) |
| 281 | + equil.boundary = qfm_surf |
| 282 | + equil.run() |
| 283 | + |
| 284 | +t_end = time.time() |
| 285 | +print('Total time = ', t_end - t_start) |
| 286 | +# plt.show() |
0 commit comments