3D Stellar wind¶
In this example, we consider an smoothed-particle hydrodynamics (SPH) model of a companion-perturbed stellar wind.
[1]:
import numpy as np
import matplotlib.pyplot as plt
import os
from astropy import units, constants
Model setup¶
First we download a snapshot of this SPH model.
[2]:
setup_file = '3D_stellar_wind_data/wind.setup'
input_file = '3D_stellar_wind_data/wind.in'
dump_file = '3D_stellar_wind_data/wind.dump'
[3]:
!wget 'https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind.setup' --output-document $setup_file
!wget 'https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind.in' --output-document $input_file
!wget 'https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind_v10e00' --output-document $dump_file
--2024-08-12 15:29:41-- https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind.setup
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.109.133, 185.199.110.133, 185.199.111.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.109.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1209 (1.2K) [text/plain]
Saving to: ‘3D_stellar_wind_data/wind.setup’
3D_stellar_wind_dat 100%[===================>] 1.18K --.-KB/s in 0s
2024-08-12 15:29:41 (32.8 MB/s) - ‘3D_stellar_wind_data/wind.setup’ saved [1209/1209]
--2024-08-12 15:29:42-- https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind.in
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.109.133, 185.199.110.133, 185.199.111.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.109.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 5579 (5.4K) [text/plain]
Saving to: ‘3D_stellar_wind_data/wind.in’
3D_stellar_wind_dat 100%[===================>] 5.45K --.-KB/s in 0s
2024-08-12 15:29:42 (30.4 MB/s) - ‘3D_stellar_wind_data/wind.in’ saved [5579/5579]
--2024-08-12 15:29:43-- https://raw.githubusercontent.com/Ensor-code/phantom-models/main/Malfait%2B2024a/v10e00/wind_v10e00
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.109.133, 185.199.110.133, 185.199.111.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.109.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 70236960 (67M) [application/octet-stream]
Saving to: ‘3D_stellar_wind_data/wind.dump’
3D_stellar_wind_dat 100%[===================>] 66.98M 51.0MB/s in 1.3s
2024-08-12 15:29:44 (51.0 MB/s) - ‘3D_stellar_wind_data/wind.dump’ saved [70236960/70236960]
We use plons to open the data.
[4]:
import plons
setupData = plons.LoadSetup(f'{os.getcwd()}/3D_stellar_wind_data', 'wind')
dumpData = plons.LoadFullDump(f'{os.getcwd()}/{dump_file}', setupData)
position = dumpData["position"]*1e-2 # position vectors [cm -> m]
velocity = dumpData["velocity"]*1e3 # velocity vectors [km/s -> m/s]
rho = dumpData["rho"] # density [g/cm^3]
tmp = dumpData["Tgas"] # temperature [K]
tmp[tmp<2.7] = 2.7 # Cut-off temperatures below 2.7 K
# Unpack velocities
v_x, v_y, v_z = velocity.T
# Convert rho (total density) to H2 abundance
nH2 = rho * 1.0e+6 * constants.N_A.si.value / 2.02
# Define turbulence at 150 m/s
v_trb = 150.0
Next, we map the particle data to a regular Cartesian mesh.
[5]:
from pomme.haar import Haar
# Map point data to a regular grid
haar = Haar(position, q=8)
# Zoom in on the centre region to avoid edge effects
imin = 2**(haar.q-3)
imax = 3*imin
# Map data to a regular grid
nH2_dat = haar.map_data(nH2, interpolate=True)[-1][imin:imax,imin:imax,imin:imax]
tmp_dat = haar.map_data(tmp, interpolate=True)[-1][imin:imax,imin:imax,imin:imax]
v_x_dat = haar.map_data(v_x, interpolate=True)[-1][imin:imax,imin:imax,imin:imax]
v_y_dat = haar.map_data(v_y, interpolate=True)[-1][imin:imax,imin:imax,imin:imax]
v_z_dat = haar.map_data(v_z, interpolate=True)[-1][imin:imax,imin:imax,imin:imax]
TensorModel¶
With all the data in place, we can start building a pomme model. First, we store all model parameters as a TensorModel object and store this in an HDF5 file. We will use this later as the ground truth to verify our reconstructions against.
[6]:
from pomme.model import TensorModel
model = TensorModel(shape=nH2_dat.shape, sizes=haar.xyz_L)
model['log_H2' ] = np.log(nH2_dat).astype(np.float64)
model['log_temperature' ] = np.log(tmp_dat).astype(np.float64)
model['velocity_x' ] = v_x_dat .astype(np.float64)
model['velocity_y' ] = v_y_dat .astype(np.float64)
model['velocity_z' ] = v_z_dat .astype(np.float64)
model['log_v_turbulence'] = np.log(v_trb)*np.ones(model.shape, dtype=np.float64)
model.save('3D_stellar_wind_truth.h5')
GeneralModel¶
First, we define the functions that can generate the model distributions from the model parameters.
[7]:
import torch
from pomme.utils import planck, T_CMB
def get_velocity(model):
"""
Get the velocity from the TensorModel.
"""
return model['velocity_z']
def get_temperature(model):
"""
Get the temperature from the TensorModel.
"""
return torch.exp(model['log_temperature'])
def get_abundance(model, l):
"""
Get the abundance from the TensorModel.
"""
# Define the assumed molecular fractions w.r.t. H2
X_mol = [3.0e-4, 5.0e-6]
# Return the abundance
return torch.exp(model['log_H2']) * X_mol[l]
def get_turbulence(model):
"""
Get the turbulence from the TensorModel.
"""
return torch.exp(model['log_v_turbulence'])
def get_boundary_condition(model, frequency):
"""
Get the boundary condition from the TensorModel.
model: TensorModel
The TensorModel object containing the model.
frequency: float
Frequency at which to evaluate the boundary condition.
"""
# Compute the incoming boundary intensity
Ibdy = torch.ones((model.shape[0], model.shape[1], len(frequency)), dtype=torch.float64)
Ibdy *= planck(temperature=T_CMB, frequency=frequency)
# Return the result
return Ibdy
Using these functions, we can build a GeneralModel object that can be used to generate synthetic observations or reconstruct the required parameters. (Cfr. the SphericalModel class for spherically symmetric models.)
[8]:
from pomme.model import GeneralModel
gmodel_truth = GeneralModel(model=model)
gmodel_truth.get_velocity = get_velocity
gmodel_truth.get_abundance = get_abundance
gmodel_truth.get_turbulence = get_turbulence
gmodel_truth.get_temperature = get_temperature
gmodel_truth.get_boundary_condition = get_boundary_condition
Spectral lines¶
We base our reconstructions on synthetic observations of two commonly observed rotational lines CO \(J=4-3\) and SiO \(J=3-2\). We explicitly provide the molar mass for SiO, since this is not extracted correctly from the line data file.
[9]:
from pomme.lines import Line
from pomme.utils import get_molar_mass
lines = [
Line(species_name='CO', transition=3),
Line(species_name='sio-h2', transition=2, molar_mass=get_molar_mass('SiO'))
]
You have selected line:
CO(J=4-3)
Please check the properties that were inferred:
Frequency 4.610407682e+11 Hz
Einstein A coeff 6.126000000e-06 1/s
Molar mass 28.0101 g/mol
You have selected line:
sio-h2(J=03-02)
Please check the properties that were inferred:
Frequency 1.302686830e+11 Hz
Einstein A coeff 1.058000000e-04 1/s
Molar mass 44.0849 g/mol
/home/frederikd/.local/lib/python3.9/site-packages/astroquery/lamda/core.py:145: UserWarning: The first time a LAMDA function is called, it must assemble a list of valid molecules and URLs. This list will be cached so future operations will be faster.
warnings.warn("The first time a LAMDA function is called, it must "
Frequencies¶
Next, we define the velocity/frequency range. We observe the lines in 100 frequency bins, centred around the lines, with a spacing of 120 m/s.
[10]:
vdiff = 120 # velocity increment size [m/s]
nfreq = 100 # number of frequencies
velocities = nfreq * vdiff * torch.linspace(-1, +1, nfreq, dtype=torch.float64)
frequencies = [(1.0 + velocities / constants.c.si.value) * line.frequency for line in lines]
Synthetic observations¶
We can now generate synthetic observations, directly from the Model object. We will use these later to derive our reconstructions.
[11]:
obss = gmodel_truth.image(lines, frequencies)
Plot the resulting synthetic spectral line observations. pomme provides some convenient wrappers for matploltib to explore the spectral data cubes.
[12]:
from pomme.plot import plot_cube_2D, plot_spectrum
plot_cube_2D(np.log(obss[0]))
plot_cube_2D(np.log(obss[1]))
plot_spectrum(np.log(obss[0]))
plot_spectrum(np.log(obss[1]))
[12]:
<function pomme.plot.plot_spectrum.<locals>.plot(i, j)>
Reconstruction setup¶
In this example, we will try to reconstruct the CO abundance, radial velocity, and temperature distribution. Since we will only assume a radial component in our velocity field, we need to adapt the corresponding function.
[13]:
def get_velocity_from_1D(model):
"""
Get the velocity from the 1D data.
"""
# Extract the radial unit vector field from the model
d = torch.from_numpy(model.get_radial_direction(origin='centre'))
# Return the (strictly radial) velocity field
return torch.exp(model['log_velocity_r']) * d
Next, we define the model object for the reconstruction. Note that in the gmodel defined above, all the right parameters are already stored, so we need a new one for the reconstruction. We take, $n_{\text{CO}}^{\text{init}}(r) = 5.0 \times `10^{14} , :nbsphinx-math:text{m}`^{-3} , (r_{\text{in}}/r)^{2} $, as initial guess for the CO abundance distribution, and initialise both the velocity and the temperature with the correct values.
[14]:
gmodel_recon = GeneralModel(gmodel_truth.model)
gmodel_recon.get_velocity = get_velocity_from_1D
gmodel_recon.get_abundance = get_abundance
gmodel_recon.get_turbulence = get_turbulence
gmodel_recon.get_temperature = get_temperature
gmodel_recon.get_boundary_condition = get_boundary_condition
# Initial guess parameters
n_H2_in = 5.0e+13
v_in = 1.0e+2
v_inf = 1.0e+4
beta = 1.0
T_in = 5.0e+3
epsilon = 0.5
rs = gmodel_recon.model.get_radius(origin='centre')
model['log_H2' ] = np.log(n_H2_in * (rs.min()/rs)**2)
model['log_velocity_r' ] = np.log(v_in + (v_inf - v_in) * (1.0 - rs.min()/rs)**beta)
model['log_temperature' ] = np.log(T_in * (rs.min()/rs)**epsilon)
# Fix all parameters, except for the ones we want to fit
model.fix_all()
model.free(['log_H2', 'log_velocity_r', 'log_temperature'])
# Save the initial guess
model.save('3D_stellar_wind_recon_init.h5')
We can explore the model parameters with the info() function.
[15]:
gmodel_recon.model.info()
Variable key: Free/Fixed: Field: Min: Mean: Max:
log_H2 Free True +2.326e+01 +2.451e+01 +3.154e+01
log_temperature Free True +6.446e+00 +6.759e+00 +8.517e+00
velocity_x Fixed True -1.958e+04 +4.669e+00 +1.754e+04
velocity_y Fixed True -1.741e+04 -2.048e+02 +2.137e+04
velocity_z Fixed True -9.749e+03 -7.321e+01 +9.680e+03
log_v_turbulence Fixed True +5.011e+00 +5.011e+00 +5.011e+00
log_velocity_r Free True +4.605e+00 +9.178e+00 +9.195e+00
sizes: [7.46717267e+13 7.46169581e+13 7.36127621e+13]
shape: (64, 64, 64)
Loss functions¶
We first create Loss object that can conveniently store the different losses. We will use a reproduction loss (split into an averaged and relative component), a smoothness, and a continuity loss.
[16]:
from pomme.loss import Loss, diff_loss
losses = Loss(['avg', 'rel', 'smt', 'cnt'])
Reproduction loss¶
We split the reproduction loss into an averaged and a relative component, \begin{equation*} \mathcal{L}_{\text{rep}}\big(f(\boldsymbol{m}), \boldsymbol{o} \big) \ = \ \mathcal{L}_{\text{rep}}\Big( \big\langle f(\boldsymbol{m}) \big\rangle, \, \left\langle\boldsymbol{o}\right\rangle \Big) \ + \ \mathcal{L}_{\text{rep}}\left( \frac{f(\boldsymbol{m})}{\big\langle f(\boldsymbol{m})\big\rangle}, \, \frac{\boldsymbol{o}}{\left\langle \boldsymbol{o}\right\rangle} \right) , \end{equation*}
[17]:
# Define averaging and relative function
avg = lambda arr: arr.mean(axis=-1)
rel = lambda arr: torch.einsum("...f, ... -> ...f", arr, 1.0/avg(arr))
def avg_loss(smodel, imgs):
"""
Compute the average loss.
"""
return torch.nn.functional.mse_loss(avg(imgs), avg(obss))
def rel_loss(smodel, imgs):
"""
Compute the relative loss.
"""
return torch.nn.functional.mse_loss(rel(imgs), rel(obss))
Smoothness loss¶
The smoothnes loss is defined as, \begin{equation} \mathcal{L}[q] \ = \ \int \text{d} \boldsymbol{x} \ \| \nabla q(\boldsymbol{x})\|^{2} \end{equation}
[18]:
def smoothness_loss(gmodel):
"""
Smoothness loss for CO, velocity, and temperature distributions.
"""
# Compute and return the loss
return ( diff_loss(gmodel.model['log_H2' ]) \
+ diff_loss(gmodel.model['log_velocity_r' ]) \
+ diff_loss(gmodel.model['log_temperature']) )
Continuity loss¶
The regularisation loss that assumes a steady state and enforces the continuity equation, in 3D reads, \begin{equation*} \mathcal{L}[\rho, \boldsymbol{v}] \ = \ \int \text{d}\boldsymbol{x} \ \frac{1}{\rho^{2}} \big( \nabla \cdot \left( \rho \, \boldsymbol{v} \right) \big)^{2} \end{equation*}
[19]:
def steady_state_cont_loss(gmodel):
"""
Loss assuming steady state hydrodynamics, i.e. vanishing time derivatives.
"""
# Extract the relevant variables form the model
rho = gmodel.get_abundance(gmodel.model, 0) # line number does not matter here
v_x, v_y, v_z = gmodel.get_velocity (gmodel.model)
# Continuity equation (steady state): div(ρ v) = 0
loss_cont = gmodel.model.diff_x(rho * v_x) + gmodel.model.diff_y(rho * v_y) + gmodel.model.diff_z(rho * v_z)
# Squared average over the model
loss_cont = torch.mean((loss_cont / rho)**2)
# Return losses
return loss_cont
Fit function¶
With everything in place, we can finally define the fit function.
[20]:
from torch.optim import Adam
from tqdm import tqdm
def fit(losses, gmodel, N_epochs=10, lr=1.0e-1, w_avg=1.0, w_rel=1.0, w_smt=1.0, w_cnt=1.0):
# Define optimiser
optimizer = Adam(model.free_parameters(), lr=lr)
# Iterate optimiser
for _ in tqdm(range(N_epochs)):
# Forward model
imgs = gmodel.image(lines, frequencies)
# Compute the losses
losses['avg'] = w_avg * avg_loss(gmodel, imgs)
losses['rel'] = w_rel * rel_loss(gmodel, imgs)
losses['smt'] = w_smt * smoothness_loss(gmodel)
losses['cnt'] = w_cnt * steady_state_cont_loss(gmodel)
# Set gradients to zero
optimizer.zero_grad()
# Backpropagate gradients
losses.tot().backward()
# Update parameters
optimizer.step()
# Return the images and losses
return imgs, losses
Experiments¶
Now we can fit the model to the synthetic data. First, to determine the relative weights for each loss function in the total loss, we run 3 iterations and observe the loss values. Then, we define the weight of each individual loss function by the inverse of its current value (renormalisation step), such that in the following iterations they all contribute equally.
[ ]:
imgs, losses = fit(losses, gmodel_recon,
N_epochs = 3,
lr = 1.0e-1,
w_avg = 1.0e+0,
w_rel = 1.0e+0,
w_smt = 1.0e+0,
w_cnt = 1.0e+0,
)
losses.renormalise_all() # Renormalise the losses
losses.reset() # Reset the losses to renormalised values
# Save the (partially) reconstructed model
gmodel_recon.model.save('3D_stellar_wind_recon_000.h5')
100%|██████████| 3/3 [00:42<00:00, 14.11s/it]
With the renormalised weights in place, we now run 100 iterations of the reconstruction algorithm. Note that one can still specify relative weights for the losses, these are an additional factor to the renormalisation. Here, we take them all equal to one, such that all losses contribute equally.
[33]:
imgs, losses = fit(losses, gmodel_recon,
N_epochs = 100,
lr = 1.0e-1,
w_avg = 1.0e+0,
w_rel = 1.0e+0,
w_smt = 1.0e+0,
w_cnt = 1.0e+0,
)
gmodel_recon.model.save('3D_stellar_wind_recon_100.h5')
losses.plot()
100%|██████████| 100/100 [22:09<00:00, 13.30s/it]
Next, we run an additional 500 itereations of the reconstruction algortihm.
[ ]:
imgs, losses = fit(losses, gmodel_recon,
N_epochs = 500,
lr = 1.0e-1,
w_avg = 1.0e+0,
w_rel = 1.0e+0,
w_smt = 1.0e+0,
w_cnt = 1.0e+0,
)
gmodel_recon.model.save('3D_stellar_wind_recon_600.h5')
losses.plot()
100%|██████████| 100/100 [18:03<00:00, 10.84s/it]
[21]:
gmodel_recon.model = TensorModel.load('3D_stellar_wind_recon_600.h5')
[22]:
plot_cube_2D(gmodel_recon.model['log_H2'])
[22]:
<function pomme.plot.plot_cube_2D.<locals>.plot(z)>
[23]:
plot_cube_2D(gmodel_recon.model['log_temperature'])
[23]:
<function pomme.plot.plot_cube_2D.<locals>.plot(z)>
[24]:
plot_cube_2D(gmodel_recon.model['log_velocity_r'])
[24]:
<function pomme.plot.plot_cube_2D.<locals>.plot(z)>
Note that, although we started form an spherically symmetric initial guess, we can recover the spiral structure in the abundance, temperature, and velocity distributions. See our paper for a complete analysis of this model and the reconstruciton.