Source code for sunkit_magex.pfss.output

import warnings
import functools

import numpy as np

import astropy.units as u
from astropy.coordinates import SkyCoord

import sunpy.map

import sunkit_magex.pfss.coords

# Default colourmap for magnetic field maps
_MAG_CMAP = 'RdBu'


[docs] class Output: ''' Output of PFSS modelling. Parameters ---------- alr : Vector potential * grid spacing in radial direction. als : Vector potential * grid spacing in elevation direction. alp : Vector potential * grid spacing in azimuth direction. grid : Grid Grid that the output was calculated on. input_map : sunpy.map.GenericMap The input map. Notes ----- Instances of this class are intended to be created by `sunkit_magex.pfss.pfss`, and not by users. ''' def __init__(self, alr, als, alp, grid, input_map=None): self._alr = alr self._als = als self._alp = alp self.grid = grid self.input_map = input_map # Cache attributes self._common_b_cache = None self._rgi = None def _wcs_header(self): """ Construct a world coordinate system describing the sunkit_magex.pfss solution. """ return self.input_map.wcs @property def _lon0(self): """Longitude offset of the map.""" return self.input_map.reference_coordinate.lon @property def coordinate_frame(self): """ Coordinate frame of the input map. Notes ----- This is either a `~sunpy.coordinates.frames.HeliographicCarrington` or `~sunpy.coordinates.frames.HeliographicStonyhurst` frame, depending on the input map. """ return self.input_map.coordinate_frame @property def dtime(self): """ Date and time of the input map. """ return self.input_map.reference_date @property def bunit(self): """ `~astropy.units.Unit` of the input map data. If no metadata is available, returns dimensionless units. """ unit = self.input_map.unit return u.dimensionless_unscaled if unit is None else unit @property def source_surface_br(self): """ Radial magnetic field component on the source surface. Returns ------- :class:`sunpy.map.GenericMap` """ # Get radial component at the top br = self.bc[0][:, :, -1].to_value(self.bunit) # Remove extra ghost cells off the edge of the grid m = sunpy.map.Map((br.T, self._wcs_header())) if 'bunit' in self.input_map.meta: m.meta['bunit'] = self.input_map.meta['bunit'] vlim = np.max(np.abs(br)) m.plot_settings['cmap'] = _MAG_CMAP m.plot_settings['vmin'] = -vlim m.plot_settings['vmax'] = vlim return m @property def source_surface_pils(self): """ Coordinates of the polarity inversion lines on the source surface. Notes ----- This is always returned as a list of `~astropy.coordinates.SkyCoord`, as in general there may be more than one polarity inversion line. """ from skimage import measure m = self.source_surface_br contours = measure.find_contours(m.data, 0) contours = [m.wcs.pixel_to_world(c[:, 1], c[:, 0]) for c in contours] return contours @property def _brgi(self): """ Regular grid interpolator for B. """ from sunkit_magex.pfss.interpolator import RegularGridInterpolator as rgi if self._rgi is not None: return self._rgi f32 = np.float32 # - (rho,s,phi) coordinates: rho = self.grid.rg.astype(f32) s = self.grid.sg.astype(f32) phi = self.grid.pg.astype(f32) br, bth, bph = self.bg[..., 2], self.bg[..., 1], self.bg[..., 0] # Because we need the cartesian grid to stretch just beyond r=rss, # add an extra dummy layer of magnetic field pointing radially outwards rho = np.append(rho, rho[-1] + 0.01) extras = np.ones(br.shape[0:2] + (1, )) * self.bunit br = np.concatenate((br, extras), axis=2).astype(f32) bth = np.concatenate((bth, 0 * extras), axis=2).astype(f32) bph = np.concatenate((bph, 0 * extras), axis=2).astype(f32) # - convert to Cartesian components and make interpolator on # (rho,s,phi) grid: ph3, s3, rh3 = np.meshgrid(phi, s, rho, indexing='ij') sin_th = np.sqrt(1 - s3**2) cos_th = s3 sin_ph = np.sin(ph3) cos_ph = np.cos(ph3) # Directly stack the expressions below, to save a bit of memory # bx = (sin_th * cos_ph * br) + (cos_th * cos_ph * bth) - (sin_ph * bph) # by = (sin_th * sin_ph * br) + (cos_th * sin_ph * bth) + (cos_ph * bph) # bz = (cos_th * br) - (sin_th * bth) bstack = np.stack(((sin_th * cos_ph * br) + (cos_th * cos_ph * bth) - (sin_ph * bph), (sin_th * sin_ph * br) + (cos_th * sin_ph * bth) + (cos_ph * bph), (cos_th * br) - (sin_th * bth)), axis=-1) self._rgi = rgi((phi, s, rho), bstack) return self._rgi def _bTrace(self, t, coord, direction): """ Return B/|B| for use by the field line tracer. """ x, y, z = coord # (ph, s, rh) coordinates of current point: rho, s, phi = sunkit_magex.pfss.coords.cart2strum(x, y, z) # Check if position vector is outside the data limits if rho < 0 or rho > np.log(self.grid.rss): return np.array([0, 0, 0]) # raise _OutOfBoundsError b1 = self._brgi(np.stack((phi, s, rho)))[0] return direction * b1 / np.linalg.norm(b1)
[docs] def trace(self, tracer, seeds): """ Trace magnetic field lines. Parameters ---------- tracer : tracing.Tracer Field line tracer. seeds : astropy.coordinates.SkyCoord Starting coordinates. """ return tracer.trace(seeds, self)
def _integrate_one_way(self, dt, start_point, rtol, atol): import scipy.integrate direction = np.sign(dt) def finish_integration(t, coord): r = np.linalg.norm(coord) ret = (r - 1) * (r - self.grid.rss) return ret finish_integration.terminal = True # The integration domain is deliberately huge, because the # the integration automatically stops when an out of bounds error # is thrown t_span = (0, 1e4) def fun(t, y): return self._bTrace(t, y, direction) res = scipy.integrate.solve_ivp( fun, t_span, start_point, method='RK23', rtol=rtol, atol=atol, events=finish_integration) xout = res.y return xout @property def _al(self): """ Vector potential times cell edge lengths. Returns ar*Lr, as*Ls, ap*Lp on cell edges. """ return self._alr, self._als, self._alp @property def bc(self): """ B on the centres of the cell faces. Returns ------- br : astropy.units.Quantity A ``nphi, ns, nrho + 1`` shaped Quantity. btheta : astropy.units.Quantity A ``nphi, ns + 1, nrho`` shaped Quantity. bphi : astropy.units.Quantity A ``nphi + 1, ns, nrho`` shaped Quantity. Notes ----- The three components are not co-located at the same locations. Component ``n`` is located on cell faces of constant ``n``, e.g. the ``r`` component is located on the cell faces at constant ``r`` values. """ br, bs, bp, Sbr, Sbs, Sbp = self._common_b() # Remove area factors: br = br.copy() bs = bs.copy() bp = bp.copy() for i in range(self.grid.nphi + 2): br[i, :, :] = br[i, :, :] / Sbr bs[i, :, :] = bs[i, :, :] / Sbs for i in range(self.grid.nphi + 1): bp[i, :, :] = bp[i, :, :] / Sbp # Slice to remove ghost cells return (br[1:-1, 1:-1, :] * self.bunit, -bs[1:-1, :, 1:-1] * self.bunit, bp[:, 1:-1, 1:-1] * self.bunit) @property @functools.lru_cache(maxsize=1) def bg(self): """ B as a (weighted) averaged on grid points. Returns ------- astropy.units.Quantity A ``(nphi + 1, ns + 1, nrho + 1, 3)`` shaped Quantity. The last index gives the corodinate axis, 0 for Bphi, 1 for Bs, 2 for Brho. Because the phi dimension is periodic, ``bg[0, :, :] == bg[-1, :, :]``. """ br, bs, bp, Sbr, Sbs, Sbp = self._common_b() # Weighted average to grid points: brg = br[:-1, :-1, :] + br[1:, :-1, :] + br[1:, 1:, :] + br[:-1, 1:, :] bsg = bs[:-1, :, :-1] + bs[1:, :, :-1] + bs[1:, :, 1:] + bs[:-1, :, 1:] bpg = bp[:, :-1, :-1] + bp[:, 1:, :-1] + bp[:, 1:, 1:] + bp[:, :-1, 1:] for i in range(self.grid.nphi + 1): brg[i, :, :] /= 2 * (Sbr[:-1, :] + Sbr[1:, :]) bsg[i, :, :] /= 2 * (Sbs[:, :-1] + Sbs[:, 1:]) for i in range(self.grid.nphi + 1): bpg[i, :, :] /= (Sbp[:-1, :-1] + Sbp[1:, :-1] + Sbp[1:, 1:] + Sbp[:-1, 1:]) bsg *= -1 out = np.stack((bpg, bsg, brg), axis=-1) out.flags.writeable = False return out * self.bunit @property @functools.lru_cache(maxsize=1) def _modbg(self): return np.linalg.norm(self.bg, axis=-1) def _common_b(self): """ Common code needed to calculate magnetic field from vector potential. """ if self._common_b_cache is not None: return self._common_b_cache dr = self.grid.dr ds = self.grid.ds dp = self.grid.dp nr = self.grid.nr ns = self.grid.ns nphi = self.grid.nphi rss = self.grid.rss rc = self.grid.rc sc = self.grid.sc rg = self.grid.rg sg = self.grid.sg alr, als, alp = self._al # Centre of cells in rho (including ghost cells) rc = np.linspace(-0.5 * dr, np.log(rss) + 0.5 * dr, nr + 2) rrc = np.exp(rc) thc = np.zeros(ns + 2) - 1 thc[1:-1] = np.arccos(sc) # Centre of cells in phi (including ghost cells) np.linspace(-0.5 * dp, 2 * np.pi + 0.5 * dp, nphi + 2) # Required face normals: dnp = np.zeros((ns + 2, 2)) dns = np.zeros((ns + 1, 2)) dnr = np.zeros(ns + 2) for k in range(2): for j in range(1, ns + 1): dnp[j, k] = rrc[k] * np.sqrt(1 - sc[j - 1]**2) * dp dnp[0, k] = dnp[1, k] dnp[-1, k] = dnp[-2, k] for j in range(1, ns): dns[j, k] = rrc[k] * (np.arcsin(sc[j]) - np.arcsin(sc[j - 1])) dns[0, k] = dns[1, k] dns[-1, k] = dns[-2, k] for j in range(ns + 2): dnr[j] = rrc[0] * (np.exp(dr) - 1) dnr[0] = -dnr[0] dnr[-1] = -dnr[-1] # Required area factors: Sbr = np.zeros((ns + 2, nr + 1)) for k in range(nr + 1): Sbr[1:-1, k] = np.exp(2 * rg[k]) * ds * dp Sbr[0, k] = Sbr[1, k] Sbr[-1, k] = Sbr[-2, k] Sbs = np.zeros((ns + 1, nr + 2)) for k in range(nr + 2): for j in range(1, ns): Sbs[j, k] = 0.5 * np.exp(2 * rc[k] - dr) * dp * (np.exp(2 * dr) - 1) * np.sqrt(1 - sg[j]**2) Sbs[0, k] = Sbs[1, k] Sbs[-1, k] = Sbs[-2, k] Sbp = np.zeros((ns + 2, nr + 2)) for k in range(nr + 2): for j in range(1, ns + 1): Sbp[j, k] = 0.5 * np.exp(2 * rc[k] - dr) * (np.exp(2 * dr) - 1) * (np.arcsin(sg[j]) - np.arcsin(sg[j - 1])) Sbp[0, k] = Sbp[1, k] Sbp[-1, k] = Sbp[-2, k] # Compute br*Sbr, bs*Sbs, bp*Sbp at cell centres by Stokes theorem: br = np.zeros((nphi + 2, ns + 2, nr + 1)) bs = np.zeros((nphi + 2, ns + 1, nr + 2)) bp = np.zeros((nphi + 1, ns + 2, nr + 2)) br[1:-1, 1:-1, :] = als[1:, :, :] - als[:-1, :, :] + alp[:, :-1, :] - alp[:, 1:, :] bs[1:-1, :, 1:-1] = alp[:, :, 1:] - alp[:, :, :-1] bp[:, 1:-1, 1:-1] = als[:, :, :-1] - als[:, :, 1:] # Fill ghost values with boundary conditions: # - zero-gradient at outer boundary: bs[1:-1, :, -1] = 2 * bs[1:-1, :, -2] - bs[1:-1, :, -3] bp[:, 1:-1, -1] = 2 * bp[:, 1:-1, -2] - bp[:, 1:-1, -3] # - periodic in phi: bs[0, :, :] = bs[-2, :, :] bs[-1, :, :] = bs[1, :, :] br[0, :, :] = br[-2, :, :] br[-1, :, :] = br[1, :, :] # js = jp = 0 at photosphere: for i in range(nphi + 1): bp[i, :, 0] = Sbp[:, 0] / dnp[:, 0] * (bp[i, :, 1] * dnp[:, 1] / Sbp[:, 1] + br[i, :, 0] * dnr[:] / Sbr[:, 0] - br[i + 1, :, 0] * dnr[:] / Sbr[:, 0]) for i in range(nphi + 2): bs[i, :, 0] = Sbs[:, 0] / dns[:, 0] * (bs[i, :, 1] * dns[:, 1] / Sbs[:, 1] + br[i, :-1, 0] * dnr[:-1] / Sbr[:-1, 0] - br[i, 1:, 0] * dnr[1:] / Sbr[1:, 0]) # - polar boundaries as in dumfric: for i in range(nphi + 2): i1 = (i + nphi // 2) % nphi br[i, -1, :] = br[i1, -2, :] br[i, 0, :] = br[i1, 1, :] bs[i, -1, :] = 0.5 * (bs[i, -2, :] - bs[i1, -2, :]) bs[i, 0, :] = 0.5 * (bs[i, 1, :] - bs[i1, 1, :]) for i in range(nphi + 1): i1 = (i + nphi // 2) % nphi bp[i, -1, :] = -bp[i1, -2, :] bp[i, 0, :] = -bp[i1, 1, :] self._common_b_cache = br, bs, bp, Sbr, Sbs, Sbp return self._common_b_cache
[docs] def get_bvec(self, coords, out_type="spherical"): """ Interpolate magnetic vectors at arbitrary coordinates. Parameters ---------- coords : `astropy.coordinates.SkyCoord` An arbitrary point or set of points (length N >= 1) in the PFSS model domain (1Rs < r < Rss). out_type : str Takes values 'spherical' (default) or 'cartesian' and specifies whether the output vector is in spherical coordinates (B_r, B_theta, B_phi) or cartesian (B_x, B_y, B_z). Returns ------- bvec : astropy.units.Quantity (N, 3) shaped Quantity of magnetic field vectors. Notes ----- The output coordinate system is defined by the input magnetogram with x-z plane equivalent to the plane containing the Carrington meridian (0 deg longitude) The spherical coordinates follow the physics convention: https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/3D_Spherical.svg/240px-3D_Spherical.svg.png) Therefore the polar angle (theta) is the co-latitude, rather than the latitude, with range 0 (north pole) to 180 degrees (south pole) The conversion which relates the spherical and cartesian coordinates is as follows: .. math:: B_R = sin\\theta cos\\phi B_x + sin\\theta sin\\phi B_y + cos\\theta B_z .. math:: B_\\theta = cos\\theta cos\\phi B_x + cos\\theta sin\\phi B_y - sin\\theta B_z .. math:: B_\\phi = -sin\\phi B_x + cos\\phi B_y The above equations may be written as a (3x3) matrix and inverted to retrieve the inverse transformation (cartesian from spherical) """ # Assert skycoord is type astropy.coordinates.SkyCoord if not isinstance(coords, SkyCoord): raise ValueError("coords must be of type " "astropy.coordinates.SkyCoord") # Ensure representation type is spherical for input to interpolator coords.representation_type = "spherical" # Check coord_type is cartesian or spherical if out_type not in ["cartesian", "spherical"]: raise ValueError("out_type must be 'cartesian' or 'spherical' " f"(got {out_type})") # Raise warning if input skycoord obstime does not match # self.coordinate_frame.obstime if np.any(self.coordinate_frame.obstime.to_datetime() != coords.obstime.to_datetime()): warnings.warn("The obstime of one of more input coordinates " "do not match the pfss model obstime.") # Convert SkyCoord to sunkit_magex.pfss.Output coordinate frame coords.transform_to(self.coordinate_frame) # Do interpolation (returns cartesian vector) bvecs = self._brgi(np.array([coords.lon.to("rad").value, np.sin(coords.lat).value, np.log(coords.radius.to("R_sun").value)]).T ) # Convert to spherical if requested if out_type == "spherical": # Generate vector of 3x3 rotation matrices M = np.array([ [np.cos(coords.lat).value * np.cos(coords.lon).value, np.cos(coords.lat).value * np.sin(coords.lon).value, np.sin(coords.lat).value], [np.sin(coords.lat).value * np.cos(coords.lon).value, np.sin(coords.lat).value * np.sin(coords.lon).value, -np.cos(coords.lat).value], [-np.sin(coords.lon).value, np.cos(coords.lon).value, np.zeros(len(coords))], ]) bvecs = np.array([np.dot(M_.T, v) for M_, v in zip(M.T, bvecs)]) return bvecs * self.bunit