Source code for gtsimulation.magnetic_field.heliosphere._parker

import datetime
import numpy as np
from numba import njit, prange

from gtsimulation.common import Units, Regions
from gtsimulation.magnetic_field import AbsBfield
from gtsimulation.magnetic_field.heliosphere.Functions import transformations


[docs] class Parker(AbsBfield): """ Parker spiral model of the interplanetary magnetic field with optional heliospheric current sheet (HCS), corotating interaction regions (CIR), and turbulent fluctuations (slab + 2D components). The regular part is the classical Parker field with a possible HCS whose tilt angle varies with the solar cycle. Turbulence is simulated as a superposition of slab and 2D modes following the approach described in [1]_. Parameters ---------- date : datetime.date or int, optional Initial epoch. If int, interpreted as seconds from an arbitrary reference; if datetime.date, converted to Unix time (seconds since 1970-01-01). Default 0. magnitude : float, default=2.09 Magnetic field magnitude at 1 AU (nT). polarity : int, default=-1 Polarity sign of the field (+1 or -1). tilt_angle : float or None, optional Constant tilt angle of the heliospheric current sheet (radians). If None (default), the tilt angle varies with the 11‑year solar cycle according to an empirical fit. use_reg : bool, default=True Whether to include the regular Parker field (including HCS if enabled). use_hcs : bool, default=True Whether to include the heliospheric current sheet modulation. use_cir : bool, default=False Whether to include CIR effects (not implemented yet). use_noise : bool, default=False Whether to add turbulent fluctuations. use_slab : bool, default=True Whether to include the slab component of turbulence. use_2d : bool, default=True Whether to include the 2D component of turbulence. noise_num : int, default=256 Number of modes used in the turbulence spectrum. log_kmin : float, default=1 Decimal logarithm of the minimum wave number (AU⁻¹). log_kmax : float, default=6 Decimal logarithm of the maximum wave number (AU⁻¹). coeff_noise : float, default=0.47 Scaling factor for the turbulent component. coeff_2d : float, default=2.9 Scaling factor for the 2D component of turbulence. **kwargs Additional arguments passed to the base class `AbsBfield`. Notes ----- The regular Parker field is given by: B_r = A0 / r² * HCS B_φ = -A0 / r² * ((r - rs) ω / v_sw) sinθ * HCS where HCS is the current sheet modulation (tanh profile). Turbulence is simulated as a sum of slab and 2D modes following the synthetic turbulence model described in [1]_. The implementation closely follows the formulas provided there. References ---------- .. [1] Engelbrecht, N. E., et al. (2022). ApJ, 941(2), 168. doi:10.3847/1538-4357/aca892 Examples -------- >>> from gtsimulation.magnetic_field.heliosphere import Parker Create a model with default parameters (regular field only, no noise). Compute the field at Earth's orbit (1 AU along the X-axis). >>> model = Parker() >>> x, y, z = 1.0, 0.0, 0.0 # coordinates in AU >>> Bx, By, Bz = model.CalcBfield(x, y, z) >>> print(f"B = ({Bx:.2f}, {By:.2f}, {Bz:.2f}) nT") Override the HCS tilt angle with a constant value (e.g., 0.5 rad). >>> model_tilt = Parker(tilt_angle=0.5) >>> Bx_t, By_t, Bz_t = model_tilt.CalcBfield(-x, y, z) >>> print(f"B = ({Bx:.2f}, {By:.2f}, {Bz:.2f}) nT") """ ToMeters = Units.AU2m rs = 0.0232523 omega = 2 * np.pi / 2160000 years11 = 347133600 km2AU = 1 / Units.AU2km def __init__(self, date: datetime.date | int = 0, magnitude=2.09, polarity=-1, tilt_angle=None, use_reg=True, use_hcs=True, use_cir=False, use_noise=False, use_slab=True, use_2d=True, noise_num=256, log_kmin=1, log_kmax=6, coeff_noise=0.47, coeff_2d=2.9, **kwargs): super().__init__(**kwargs) self.Region = Regions.Heliosphere self.ModelName = "Parker" self.Units = "AU" self.magnitude = magnitude self.polarity = polarity self.tilt_angle = tilt_angle self.use_reg = use_reg self.use_hcs = use_hcs self.use_cir = use_cir self.use_slab = use_slab self.use_2d = use_2d self.coeff_noise = coeff_noise self.coeff_2d = coeff_2d self.__set_time(date) self.__set_noise(use_noise, noise_num, log_kmin, log_kmax) def __set_time(self, date: int | datetime.datetime): self.Date = date self.t = 2488320 # To have a correct phase with ace data if isinstance(date, int): self.t += date return year = date.year doy = date.timetuple().tm_yday hour = date.hour minute = date.minute second = date.second self.t += (((year * 365.25 + doy) * 24 + hour) * 60 + minute) * 60 + second
[docs] def CalcBfield(self, x, y, z, **kwargs): if kwargs.get("t") is not None: self.t = kwargs.get("t") A0 = self.magnitude * self.polarity t = self.t r, R, theta, phi = transformations.Cart2Sphere(x, y, z) v_wind = self.v_wind(theta, self.km2AU) omega = self.omega rs = self.rs use_hcs = self.use_hcs Bx = np.zeros_like(r) By = np.zeros_like(r) Bz = np.zeros_like(r) if self.use_reg: years11 = self.years11 if self.tilt_angle is None: alpha = self.CalcTiltAngle(t) dalpha = np.sign(self.CalcTiltAngle(t + 1) - self.CalcTiltAngle(t - 1)) else: alpha = self.tilt_angle dalpha = 0.0 Br, Bphi = self._calc_regular(A0, t, r, theta, phi, v_wind, omega, rs, years11, alpha, dalpha, use_hcs) # alpha -= np.pi / years11 * (r - rs) / v_wind * dalpha # # theta0 = np.pi / 2 - np.arctan(-np.tan(alpha) * np.sin(phi + omega * (r - rs) / v_wind - # omega * t)) # # HCS = self.HCS(theta, theta0, r) * (r >= rs) # Br = A0 / r ** 2 * HCS # Bphi = -A0 / r ** 2 * (((r - rs) * omega) / v_wind) * np.sin(theta) * HCS Bx, By, Bz = transformations.Sphere2Cart(Br, 0, Bphi, theta, phi) if self.use_cir: # TODO: add CIR pass if not self.use_noise: return Bx, By, Bz # coeff2d = 1.4 # coeffslab = coeff2d / 2 a = v_wind / omega A_rad = self.A_rad alpha_rad = self.alpha_rad delta_rad = self.delta_rad A_azimuth = self.A_azimuth alpha_azimuth = self.alpha_azimuth delta_azimuth = self.delta_azimuth A_2D = self.A_2D alpha_2D = self.alpha_2D delta_2D = self.delta_2D k = self.k dk = self.dk Bx_n, By_n, Bz_n = self._calc_noise(r, theta, phi, a, A_rad, alpha_rad, delta_rad, A_azimuth, alpha_azimuth, delta_azimuth, A_2D, alpha_2D, delta_2D, rs, k, dk, self.use_slab, self.use_2d, self.coeff_2d) Bx += self.magnitude * self.coeff_noise * Bx_n By += self.magnitude * self.coeff_noise * By_n Bz += self.magnitude * self.coeff_noise * Bz_n return Bx, By, Bz
[docs] def UpdateState(self, new_date): self.__set_time(new_date)
@staticmethod @njit(fastmath=True) def _calc_regular(A0, t, r, theta, phi, v_wind, omega, rs, years11, alpha, dalpha, use_hcs): HCS = 1. if use_hcs: alpha_n = alpha - np.pi / years11 * (r - rs) / v_wind * dalpha theta0 = np.pi / 2 - np.arctan(-np.tan(alpha_n) * np.sin(phi + omega * (r - rs) / v_wind - omega * t)) L = 0.0002 dt = r * (theta - theta0) / L HCS = -np.tanh(dt) Br = A0 / r ** 2 * HCS Bphi = -A0 / r ** 2 * (((r - rs) * omega) / v_wind) * np.sin(theta) * HCS return Br, Bphi
[docs] @staticmethod @njit(fastmath=True) def CalcTiltAngle(t): a0 = 0.7502 a1 = 0.02332 b1 = -0.01626 a2 = -0.3268 b2 = 0.2016 a3 = -0.02814 b3 = 0.0005215 a4 = -0.08341 b4 = -0.04852 w = 9.318e-09 alpha = (a0 + a1 * np.cos(t * w) + b1 * np.sin(t * w) + a2 * np.cos(2 * t * w) + b2 * np.sin(2 * t * w) + a3 * np.cos(3 * t * w) + b3 * np.sin(3 * t * w) + a4 * np.cos(4 * t * w) + b4 * np.sin(4 * t * w)) return alpha
[docs] @staticmethod @njit(fastmath=True) def v_wind(theta, km2AU): return (300 + 475 * (1 - np.sin(theta) ** 8)) * km2AU
[docs] @classmethod def a(cls, theta): return cls.v_wind(theta) / cls.omega
[docs] @staticmethod @njit(fastmath=True) def HCS(theta, theta0, r): L = 0.0002 dt = r * (theta - theta0) / L return -np.tanh(dt)
def __set_noise(self, use_noise, noise_num, log_kmin, log_kmax): self.use_noise = use_noise if self.use_noise is False: return self.noise_num = noise_num self.log_kmin = log_kmin self.log_kmax = log_kmax self.k = np.logspace(log_kmin, log_kmax, self.noise_num)[:, np.newaxis] self.dk = self.k * (10 ** ((log_kmax - log_kmin) / (self.noise_num - 1)) - 1) self.A_2D = np.random.randn(self.noise_num, 1) / 130 self.alpha_2D = np.random.rand(self.noise_num, 1) * 2 * np.pi n = np.trunc(np.sin(self.alpha_2D) * self.k) self.alpha_2D = np.real(np.arcsin(n / self.k) * (np.cos(self.alpha_2D) > 0) + (np.pi - np.arcsin(n / self.k)) * (np.cos(self.alpha_2D) < 0)) self.delta_2D = np.random.rand(self.noise_num, 1) * 2 * np.pi self.A_rad = np.random.randn(self.noise_num, 1) / 1.5 self.alpha_rad = np.random.rand(self.noise_num, 1) * 2 * np.pi n = np.trunc(np.sin(self.alpha_rad) * self.k) self.alpha_rad = np.real(np.arcsin(n / self.k) * (np.cos(self.alpha_rad) > 0) + (np.pi - np.arcsin(n / self.k)) * (np.cos(self.alpha_rad) < 0)) self.delta_rad = np.random.rand(self.noise_num, 1) * 2 * np.pi self.A_azimuth = np.random.randn(self.noise_num, 1) / 4.5 self.alpha_azimuth = np.random.rand(self.noise_num, 1) * 2 * np.pi n = np.trunc(np.sin(self.alpha_azimuth) * self.k) self.alpha_azimuth = np.real(np.arcsin(n / self.k) * (np.cos(self.alpha_azimuth) > 0) + (np.pi - np.arcsin(n / self.k)) * (np.cos(self.alpha_azimuth) < 0)) self.delta_azimuth = np.random.rand(self.noise_num, 1) * 2 * np.pi @staticmethod @njit(fastmath=True) def _calc_noise(r, theta, phi, a, A_rad, alpha_rad, delta_rad, A_azimuth, alpha_azimuth, delta_azimuth, A_2d, alpha_2d, delta_2d, rs, k, dk, use_slab, use_2d, component_2d): """ doi.org/10.3847/1538-4357/aca892/meta """ q_slab = 5 / 3 q_2d = 8 / 3 p = 0 gamma = 3 cospsi = 1. / np.sqrt(1 + ((r - rs) * np.sin(theta) / a) ** 2) sinpsi = ((r - rs) * np.sin(theta) / a) / np.sqrt(1 + ((r - rs) * np.sin(theta) / a) ** 2) cospsi_ = 1. / np.sqrt(1 + ((r - rs) / a) ** 2) sinpsi_ = ((r - rs) / a) / np.sqrt(1 + ((r - rs) / a) ** 2) lam_2d = 0.04 * (r / (rs / 5)) ** 0.8 * (rs / 5) dlamd_2d = 0.032 * (rs / (5 * r)) ** 0.2 lam_slab = 2 * lam_2d dlam_slab = 2 * dlamd_2d Br, Btheta, Bphi = 0., 0., 0. # TODO: calculation is point wise for mod in prange(len(k)): numer_slab = dk[mod, 0] * k[mod, 0] ** p # Radial spectrum B_rad = A_rad[mod, 0] * r ** (-gamma / 2) brk_rad = lam_slab * k[mod, 0] / np.sqrt(a * r) denom_rad = (1 + brk_rad ** (p + q_slab)) spectrum_rad = np.sqrt(numer_slab / denom_rad) deltaB_rad = 2 * B_rad * spectrum_rad * cospsi_ * r * np.sqrt(r * a) # Azimuthal spectrum B_azimuth = A_azimuth[mod, 0] * r ** (-gamma / 2) brk_azimuth = lam_slab * k[mod, 0] / r denom_azimuth = (1 + brk_azimuth ** (p + q_slab)) spectrum_azimuth = np.sqrt(numer_slab / denom_azimuth) deltaB_azimuth = B_azimuth * spectrum_azimuth dspectrum_azimuth = -spectrum_azimuth * (p + q_2d) * (denom_azimuth - 1) * (r * dlam_slab - lam_slab) / ( denom_azimuth * 2 * r * lam_slab) ddeltaB_azimtuth = B_azimuth * dspectrum_azimuth + spectrum_azimuth * B_azimuth * (-gamma / (2 * r)) # 2d spectrum B_2d = A_2d[mod, 0] * r ** (-gamma / 2) brk_2d = lam_2d * k[mod, 0] / r denom_2d = (1 + brk_2d ** (p + q_2d)) numer_2d = dk[mod, 0] * k[mod, 0] ** (p + 1) spectrum_2d = np.sqrt(2 * np.pi * numer_2d / denom_2d) deltaB_2d = B_2d * spectrum_2d dspectrum_2d = -spectrum_2d * (p + q_2d) * (denom_2d - 1) * (r * dlamd_2d - lam_2d) / ( denom_2d * 2 * r * lam_2d) ddeltaB_2d = B_2d * dspectrum_2d + spectrum_2d * B_2d * (-gamma / (2 * r)) # Radial polarization and phase phase_rad = k[mod, 0] * np.sqrt(r / a) + delta_rad[mod, 0] # Azimuthal polarization and phase phase_azimuth = k[mod, 0] * phi + delta_azimuth[mod, 0] # 2d polarization and phase phase_2d = k[mod, 0] * ((r / a + phi) * np.sin(alpha_2d[mod, 0]) + theta * np.cos(alpha_2d[mod, 0])) + \ delta_2d[mod, 0] # Radial field Br_rad = 0 Btheta_rad = -deltaB_rad * a * np.sin(alpha_rad[mod, 0]) * np.cos(phase_rad) / ( 2 * r * np.sin(theta) * np.sqrt(a * r)) Bphi_rad = deltaB_rad * a * np.cos(alpha_rad[mod, 0]) * np.cos(phase_rad) / ( 2 * r * np.sin(theta) * np.sqrt(a * r)) # Azimuthal field Br_az = -deltaB_azimuth * sinpsi_ * np.cos(alpha_azimuth[mod, 0]) * np.cos(phase_azimuth) Btheta_az = deltaB_azimuth * sinpsi_ * np.sin(alpha_azimuth[mod, 0]) * np.cos(phase_azimuth) Bphi_az = 1/k[mod, 0] * (np.sin(theta) * np.sin(phase_azimuth) * np.cos(alpha_azimuth[mod, 0]) * (2*deltaB_azimuth*sinpsi_ + r/a * deltaB_azimuth * cospsi_ + r * sinpsi_ * ddeltaB_azimtuth) - np.cos(theta)*deltaB_azimuth*np.sin(phase_azimuth)*sinpsi_*np.sin(alpha_azimuth[mod, 0])) # 2d field Br_2d = -deltaB_2d / (r * k[mod, 0] ) * (np.sin(phase_2d)*sinpsi*np.tan(theta)**(-1) + k[mod, 0]*np.cos(alpha_2d[mod, 0])*np.cos(phase_2d)*sinpsi + np.sin(phase_2d)*sinpsi*cospsi**2*np.tan(theta)**(-1)) Btheta_2d = deltaB_2d / (r * np.sin(theta)) * cospsi * np.sin(alpha_2d[mod, 0] * np.cos(phase_2d)) \ - np.sin(theta) * cospsi / (a * r * k[mod, 0]) * (ddeltaB_2d * r * (r - rs) * np.sin(phase_2d) + deltaB_2d * np.sin(phase_2d) * ( 2 * r - rs - r * sinpsi ** 2) + k[mod, 0] * r * (r - rs) / a * np.sin(alpha_2d[mod, 0]) * np.cos(phase_2d) * deltaB_2d) Bphi_2d = -deltaB_2d / (r * k[mod, 0]) * (cospsi * k[mod, 0] * np.cos(alpha_2d[mod, 0]) * np.cos(phase_2d) - (np.tan(theta))**(-1) * np.sin(phase_2d) * cospsi * sinpsi**2) # Total field coeff_slab = 0 coeff_2d = 0 if use_slab: coeff_slab = 1 if use_2d: coeff_2d = component_2d Br += coeff_2d*Br_2d + coeff_slab * (Br_az + Br_rad) Btheta += coeff_2d*Btheta_2d + coeff_slab * (Btheta_az + Btheta_rad) Bphi += coeff_2d*Bphi_2d + coeff_slab * (Bphi_az + Bphi_rad) # B = np.zeros((3, *Br_2d.shape)) # B[0] = Br_2d + coeff_slab * (Br_az + Br_rad) # B[1] = Btheta_2d + coeff_slab * (Btheta_az + Btheta_rad) # B[2] = Bphi_2d + coeff_slab * (Bphi_az + Bphi_rad) # B_s = np.sum(B, axis=1) # Br, Btheta, Bphi = B_s[0], B_s[1], B_s[2] # Br = np.sum(Br_2d + coeff_slab * (Br_az + Br_rad), axis=0) # Btheta = np.sum(Btheta_2d + coeff_slab * (Btheta_az + Btheta_rad), axis=0) # Bphi = np.sum(Bphi_2d + coeff_slab * (Bphi_az + Bphi_rad), axis=0) Bx, By, Bz = transformations.Sphere2Cart(Br, Btheta, Bphi, theta, phi) return Bx * (r > rs), By * (r > rs), Bz * (r > rs)
[docs] def to_string(self): s = f"""Parker Regular: {self.use_reg} Magnitude: {self.magnitude} HCS: {self.use_hcs} CIR: {self.use_cir} Polarity: {self.polarity} Noise: {self.use_noise} """ if self.use_noise: s += f""" Min wave length: {self.log_kmin} Max wave length: {self.log_kmax} Number of waves: {self.noise_num} Coeff_Noise: {self.coeff_noise} Coeff_2d: {self.coeff_2d} Using Slab: {self.use_slab} Using 2D: {self.use_2d}""" return s