Source code for pypec.post

#!/usr/local/env python
"""
:mod:`pypec.post` -- Post-Processing Fundamental GPEC Outputs
==============================================================

The post module is a repository of basic post-processing functions for expanding,
manipulating, and visualizing fundamental gpec output. This is not an exhaustive
list of post-processing procedures. The focus is on a) transforming between
coordinate systems and b) matrix optimizations.

Here, we show some examples using common outputs.

Examples
---------

First, add your release of GPEC to your PYTHONPATH environment variable.

In an open ipython session, import the data module

>>> from pypec import data, post

To get a typical GPEC output into python, use the open_dataset function.

>>> con = data.open_dataset('examples/DIIID_ideal_example/gpec_control_output_n1.nc')
>>> prof = data.open_dataset('examples/DIIID_ideal_example/gpec_profile_output_n1.nc')

First, 3D equilibrium calculations lend themselves naturally to 3D figures. This module
has a helpful function for forming the necessary x,y,z meshes from the 2D boundary.
After adding the requisite geometry to the control surface output,

>>> con = add_3dsurface(con, phi_lim=pi * 1.5)

it is easy to make 3D surface plots using mayavi,

>>> x,y,z = con['x_surf'].values,con['y_surf'].values,con['z_surf'].values
>>> b_n_3d = np.real(con['b_n_fun']*con['phase_surf'])
>>> fig = mmlab.figure(bgcolor=(1,1,1),fgcolor=(0,0,0),size=(600,600))
>>> mesh = mmlab.mesh(x,y,z,scalars=b_n_3d,colormap='RdBu',vmin=-2e-3,vmax=2e-3,figure=fig)

Note that mayavi explicitly requires numpy.ndarray objects so we had to
use the values of each xarray DataArray.

Now, lets cap the ends with our full profile information. This is a little
more involved. For the toroidal angle of each end, we need to rotate the
2D arrays for the mesh and the field values.

>>> for phi in [0,np.pi*3/2]:
...     b = np.real(prof['b_n_fun']*np.exp(-1j*1*phi)) # poloidal cross section at phi
...     xy = prof['R']*np.exp(1j*phi) # rotate the plane
...     x,y,z = np.real(xy),np.imag(xy),np.real(prof['z'])
...     x,y,z,b = np.array([x,y,z,b])[:,:,::10] # downsample psi by 10 to speed up mayavi
...     cap = mmlab.mesh(x,y,z,scalars=b,colormap='RdBu',vmin=-2e-3,vmax=2e-3,figure=fig)
>>> mmlab.savefig(filename='examples/figures/example_bn_3d_capped.png',figure=fig)

.. image:: examples/figures/example_bn_3d_capped.png
   :width: 600px

That looks awesome!

There are, of course, plenty more outputs and important ways of visualizing
them intuitively. See, for example, the 2D plots in the add_3dsurface
function's documentation. At this point we have outlined the basics. Using
this module and similar commands to those used here, the user should be able
to do all sorts of post-processing tasks.


"""
"""
    @package pypec
    @author NC Logan
    @email nlogan@pppl.gov
"""

import numpy as np  # math
from collections import OrderedDict

# in this package
from . import modplot as plt
from . import data
from .data import xarray, mmlab

######################################################## Global Variables

pi = np.pi
dtor = np.pi / 180


######################################################## Helper functions


[docs]def update_name_conventions(dataset, version=None, inplace=False): """ Update the naming conventions in a dataset to the latest conventions. Use this to avoid lots of if statements in cross-checking versions. :param dataset: :param version: str. Override version attribute. :return: """ # set to operate on if inplace: newset = dataset else: newset = dataset.copy(deep=True) # version number if version is None: version = dataset.attrs.get('version', None) if version is None: raise ValueError('Must specify version') version = version.split()[-1] # just the major.minor.patch numbers version = np.sum(np.array([1, 0.1, 0.001]) * map(np.float, version.split('.'))) # float representation translator = OrderedDict() if version < 0.3: # profile output changes translator['xi_psi1'] = 'xigradpsi_dpsi' translator['xi_psi'] = 'xigradpsi' translator['xi_alpha'] = 'xigradalpha' if version < 0.5: # control output changes translator['b_xn'] = 'b_n_x_fun' translator['b_n'] = 'b_n_fun' translator['xi_xn'] = 'xi_n_x_fun' translator['xi_n'] = 'xi_n_fun' translator['dphi'] = 'delta_phi' translator['b_xnm'] = 'b_n_x' translator['b_nm'] = 'b_n' translator['xi_xnm'] = 'xi_n_x' translator['xi_nm'] = 'xi_n' translator['Phi_X'] = 'Phi_x' translator['Phi_EX'] = 'Phi_xe' translator['Phi_T'] = 'Phi' translator['Phi_ET'] = 'Phi_e' translator['X_EVT'] = 'X_eigenvalue' translator['X_EDT'] = 'X_eigenvector' translator['W_EVX'] = 'W_xe_eigenvalue' translator['R_EVX'] = 'R_xe_eigenvalue' translator['P_EVX'] = 'P_xe_eigenvalue' translator['C_EVX'] = 'C_xe_eigenvalue' translator['W_EDX'] = 'W_xe_eigenvector' translator['R_EDX'] = 'R_xe_eigenvector' translator['P_EDX'] = 'P_xe_eigenvector' translator['C_EDX'] = 'C_xe_eigenvector' translator['W_EVX_energyv'] = 'W_xe_energyv' translator['W_EVX_energys'] = 'W_xe_energys' translator['W_EVX_energyp'] = 'W_xe_energyp' translator['R_EVX_energyv'] = 'R_xe_energyv' translator['R_EVX_energys'] = 'R_xe_energys' translator['R_EVX_energyp'] = 'R_xe_energyp' translator['W_EVX_A'] = 'W_xe_amp' translator['R_EVX_RL'] = 'R_xe_RL' translator['O_XT'] = 'O_Xxi_n' translator['O_WX'] = 'O_WPhi_xe' translator['O_PX'] = 'O_PPhi_xe' translator['O_RX'] = 'O_RPhi_xe' # profile output changes translator['derxi_m_contrapsi'] = 'xigradpsi_dpsi' translator['xi_m_contrapsi'] = 'xigradpsi' translator['xi_m_contraalpha'] = 'xigradalpha' # cylindrical output changes translator['b_r_plas'] = 'b_r_plasma' translator['b_z_plas'] = 'b_z_plasma' translator['b_t_plas'] = 'b_t_plasma' # do this in an explicit loop because some names already exist and need to get replaced in order for okey, nkey in translator.items(): if okey in newset: newset = newset.rename({okey: nkey}, inplace=True) return newset
######################################################## Post Processing Control Output
[docs]def add_3dsurface(control_output, phi_lim=2 * pi, nphi=180, overwrite=False, inplace=False): """ Add 3D geometric dimensions to dataset from gpec_control_output_n#.nc. Note, this surface uses the machine toroidal angle. It should only by used with functions that do the same (tmag_out=0). :param control_output: Dataset. xarray Dataset opened from gpec_control_output_n#.nc :param phi_lim: float. Toroidal angle extended from 0 to phi_lim radians. :param nphi: int. Length of toroidal angle dimension array. :param overwrite: bool. Overwrite geometric quantities if they already exist. :param inplace: bool. Modify dataset inplace. Otherwise, return a new dataset. :returns: Dataset. New dataset with additional dimensions. :Examples: After opening, adding geometry and confirming the functional forms are using the machine angle, >>> con = data.open_dataset('examples/DIIID_ideal_example/gpec_control_output_n1.nc') >>> con = add_3dsurface(con) >>> con = add_fun(con,tmag=False) it is easy to make 3D surface plots using mayavi, >>> fig = mmlab.figure(bgcolor=(1, 1, 1), fgcolor=(0,0,0), size=(600, 600)) >>> mesh = mmlab.mesh(con['x_surf'].values,con['y_surf'].values,con['z_surf'].values, ... scalars=np.real(con['b_n_fun'] * con['phase_surf']), colormap='RdBu', figure=fig) >>> mmlab.savefig(filename='examples/figures/example_bn_3d.png', figure=fig) .. image:: examples/figures/example_bn_3d.png :width: 600px Make 2D perturbed last-closed-flux-surface plots quickly using, >>> fac = 1e-1 # good for DIII-D unit eigenmodes >>> f,ax = plt.subplots() >>> ax.set_aspect('equal') >>> l, = ax.plot(con['R'], con['z'], color='k') >>> for i in range(4): # most and least stable modes ... v = np.real(con['R_xe_eigenvector_fun'][i] * fac) ... l, = ax.plot(con['R'] + v * con['R_n'], con['z'] + v * con['z_n']) >>> sm = plt.set_linearray(ax.lines[-4:], cmap='viridis') >>> cb = f.colorbar(sm, ticks=range(4)) >>> cb.set_label('Mode Index') >>> xtl = ax.set_xticks([1, 1.7, 2.4]) >>> txt = ax.set_xlabel('R (m)') >>> txt = ax.set_ylabel('z (m)') >>> f.savefig('examples/figures/example_boundary_wiggle.png') .. image:: examples/figures/example_boundary_wiggle.png :width: 600px Combining these two concepts, we can even plot a 3D perturbed surface distorted by the normal displacements and colored to show the normal field. >>> fig = mmlab.figure(bgcolor=(1, 1, 1), fgcolor=(0,0,0), size=(600, 600)) >>> xinorm = 10 * con['phase_surf'] # good for ~cm displacements >>> x = np.real(con['x_surf'].values + con['xi_n_fun'] * xinorm * con['x_surf_n']) >>> y = np.real(con['y_surf'].values + con['xi_n_fun'] * xinorm * con['y_surf_n']) >>> z = np.real(con['z_surf'].values + con['xi_n_fun'] * xinorm * con['z_surf_n']) >>> s = np.real(con['b_n_fun'] * con['phase_surf']) >>> mesh = mmlab.mesh(x,y,z,scalars=s, colormap='RdBu', figure=fig) >>> mmlab.savefig(filename='examples/figures/example_bn_xin_3d.png', figure=fig) .. image:: examples/figures/example_bn_xin_3d.png :width: 600px Fancy! """ if inplace: ds = control_output else: ds = control_output.copy(deep=True) # machine toroidal angle if 'phi_surf' not in ds or overwrite: phi = np.linspace(0, phi_lim, nphi) phi = xarray.DataArray(phi, coords={'phi_surf': phi}, dims=('phi_surf',), name='phi_surf') ds['phi_surf'] = phi ds['phase_surf'] = np.exp(-1j * ds.attrs['n'] * ds['phi_surf']) # 3D cartesian coords for 3D plots if 'x_surf' not in ds or overwrite: xy = (ds['R'] * np.exp(1j * ds['phi_surf'])).to_dataset(name='xy') ds['x_surf'] = xy.apply(np.real)['xy'] ds['y_surf'] = xy.apply(np.imag)['xy'] ds['z_surf'] = ds['z'] * (1 + 0 * ds['phi_surf']) # normal vectors xy = (ds['R_n'] * np.exp(1j * ds['phi_surf'])).to_dataset(name='xy') ds['x_surf_n'] = xy.apply(np.real)['xy'] ds['y_surf_n'] = xy.apply(np.imag)['xy'] ds['z_surf_n'] = ds['z_n'] * (1 + 0 * ds['phi_surf']) return ds
[docs]def add_fun(control_output, keys=None, tmag=False, inplace=True): """ Add real space functions of theta calculated from the spectral outputs. This is helpful when analyzing a GPEC run for which fun_flag was off for speed/efficiency. :param control_output: Dataset. xarray Dataset opened from gpec_control_output_n#.nc :param keys: iterable. List of keys for which <key>_fun will be added. Default is all spectral variables. :param tmag: bool. Keep the magnetic coordinate angle (default is machine angle). :param inplace: bool. Add to dataset. Otherwise, make a new dataset object. :returns: Dataset. New dataset with real space functions of theta. :Example: You can check this against fun_flag outputs, >>> con = data.open_dataset('examples/DIIID_kinetic_example/gpec_control_output_n1.nc') >>> con_mag = post.add_fun(con, tmag=True, inplace=False) >>> con_mac = post.add_fun(con, tmag=False, inplace=False) >>> f,ax = plt.subplots(2) >>> l, = ax[0].plot(con['theta'], np.abs(con['xi_n_fun']), label='fun_flag Output') >>> l, = ax[0].plot(con['theta'], np.abs(con_mag['xi_n_fun']), label='Magnetic Angle') >>> l, = ax[0].plot(con['theta'], np.abs(con_mac['xi_n_fun']), ls='--', label='Machine Angle') >>> leg = ax[0].legend() >>> txt = ax[0].set_title('Normal Displacement') >>> txt = ax[0].set_ylabel('Amplitude') >>> l, = ax[1].plot(con['theta'], np.angle(con['xi_n_fun']), label='fun_flag Output') >>> l, = ax[1].plot(con['theta'], np.angle(con_mag['xi_n_fun']), label='Magnetic Angle') >>> l, = ax[1].plot(con['theta'], np.angle(con_mac['xi_n_fun']), ls='--', label='Machine Angle') >>> txt = ax[1].set_ylabel('Phase') >>> txt = ax[1].set_xlabel('theta') >>> f.savefig('examples/figures/example_add_fun.png') .. image:: examples/figures/example_add_fun.png :width: 600px To see the function is properly recovered. Note that the fun_flag outputs are on the machine coordinate. """ if inplace: ds = control_output else: ds = control_output.copy(deep=True) # default to all spectral variables if keys is None: keys = [k for k, v in ds.data_vars.items() if v.dims == ('m',)] # calculate functions for k in keys: # inverse fourier transform fun = (ds['xi_n'] * np.exp(1j * (ds['m'] * ds['theta'] * 2 * pi))).sum('m') # convert to machine toroidal angle if not tmag: fun *= np.exp(1j * ds.attrs['n'] * ds['delta_phi']) # add to the dataset ds[k + '_fun'] = fun return ds
######################################################## Post Processing Control Output
[docs]def optimize_torque(matrixprofile, psilow=0, psihigh=1, normalize=False, energy=False, minimize=False): """ Calculate the eigenvalue and eigenvector corresponding to maximum the torque within the specified range of normalized poloidal flux. :param matrixprofile: DataArray. T_x or T_coil from gpec_profile_output_n#.nc. :param psilow: float. Lower limit of normalized poloidal flux. :param psihigh: float. Upper limit of normalized poloidal flux. :param normalize: bool. Optimize value within psi range normalized to the total. :param energy: bool. Optimize 2ndW (default is torque). :param minimize: bool. FInd the minimum (default is maximum). :returns: w,v. Eigenvalue, eigenvector of optimum. :Note: The matrix order will be forced to end in the primed dimension, i.e. ('psi_n', 'm', 'm_prime') or ('psi_n', 'coil_index', 'coil_index_prime'). :Examples: With the output of a kinetic GPEC run in hand, >>> con = data.open_dataset('examples/DIIID_kinetic_example/gpec_control_output_n1.nc') >>> prof = data.open_dataset('examples/DIIID_kinetic_example/gpec_profile_output_n1.nc') This function provides the spectrum that optimizes the integral torque and the corresponding profile is easily obtained. >>> w,v = optimize_torque(prof['T_xe']) # handles any order of dims >>> T_mat = prof['T_xe'].transpose('psi_n', 'm', 'm_prime') # order matters in dot products >>> T_opt = np.real(np.dot(np.dot(v.conj(), T_mat), v))/2 # 0.5 for quadratic fourier analysis We can then compare the spectrum and torque from our GPEC perturbed equilibrium to the theoretical optima, >>> f,ax = plt.subplots(2) >>> input_power = np.dot(con['Phi_xe'].conj(), con['Phi_xe']) >>> l = np.abs(con['Phi_xe']).plot(ax=ax[0], label='Input') >>> l, = ax[0].plot(con['m'], np.abs(v) * np.sqrt(input_power), label='Optimum Torque') >>> leg = ax[0].legend() >>> l = prof['T'].real.plot(ax=ax[1], label='Input') >>> l, = ax[1].plot(prof['psi_n'], T_opt*input_power, label='Optimized Torque') >>> leg = ax[1].legend() >>> f.savefig('examples/figures/example_optimize_torque.png') .. image:: examples/figures/example_optimize_torque.png :width: 600px """ # enforce correct order (default) if 'm' in matrixprofile.dims: mprof = matrixprofile.transpose('psi_n', 'm', 'm_prime') elif 'coil_index' in matrixprofile.dims: mprof = matrixprofile.transpose('psi_n', 'coil_index', 'coil_index_prime') else: raise ValueError("Expected m by m_prime or coil_index by coil_index_prime matrix") # the torque response matrix and energy response matrix are both hermitian # note the input must be a numpy matrix. xarray will un-transpose when summing. if energy: hermitian_response_matrix = lambda mat: (mat - mat.T.conj()) / 2j else: hermitian_response_matrix = lambda mat: (mat + mat.T.conj()) / 2 # total torque matrix T1 = hermitian_response_matrix(mprof.sel(method='nearest', psi_n=1).values) T1inv = np.linalg.inv(T1) # hermitian response matrices on either end of the range Th = hermitian_response_matrix(mprof.sel(method='nearest', psi_n=psihigh).values) Tl = hermitian_response_matrix(mprof.sel(method='nearest', psi_n=psilow).values) if psilow <= 0: Tl *= 0 # explicitly remove lower range # always calculate the total and local optimum to check positive definite properties Topt = Th - Tl w1, v1 = np.linalg.eig(T1) w, v = np.linalg.eig(Topt) if not (np.all(np.real(w1) >= 0) and np.all(np.real(w) >= 0)): print("WARNING: Not all eigenvalues are positive") print(" w(psi_n=1) = {:}".format(sorted(w1))) print(" w(local) = {:}".format(sorted(w))) # optimum normalized to total torque if normalize: Topt = np.dot(T1inv, Topt) w, v = np.linalg.eig(Topt) # find relevant "optimum" if minimize: i = np.argmin(np.real(w)) else: i = np.argmax(np.real(w)) return w[i], v[:, i]