metablate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ import ctypes
2
+ import numpy as np
3
+ import scipy.special as scs
4
+ import scipy.integrate as sci
5
+ import scipy.constants as consts
6
+ from tqdm import tqdm
7
+ import multiprocessing as mp
8
+
9
+
10
+ def ablated_thermal_speed_bronshten_1983(meteoroid_surface_temperature, meteoroid_molecular_mass):
11
+ return np.sqrt(
12
+ 3 * consts.R * meteoroid_surface_temperature / (meteoroid_molecular_mass * consts.N_A)
13
+ )
14
+
15
+
16
+ def plasma_frequency(electron_density):
17
+ return np.sqrt(electron_density * consts.e**2 / (consts.epsilon_0 * consts.m_e)) / (2 * np.pi)
18
+
19
+
20
+ def critical_plasma_density(critical_plasma_frequency):
21
+ return (
22
+ (critical_plasma_frequency * 2 * np.pi) ** 2 * consts.epsilon_0 * consts.m_e / (consts.e**2)
23
+ )
24
+
25
+
26
+ def mean_free_path(
27
+ ablated_thermal_speed,
28
+ total_atmospheric_number_density,
29
+ collisional_cross_section,
30
+ meteoroid_velocity,
31
+ ):
32
+ return ablated_thermal_speed / (
33
+ total_atmospheric_number_density * collisional_cross_section * meteoroid_velocity
34
+ )
35
+
36
+
37
+ def collisional_cross_section_bronshten_1983(relative_velocity):
38
+ vrel = relative_velocity * 1e-3 # requires km/s
39
+ return 5.61e-19 * vrel ** (-0.8)
40
+
41
+
42
+ def ionization_probability_Na_vondrak_2008(relative_velocity):
43
+ vrel = relative_velocity * 1e-3 # requires km/s
44
+ return 0.933 * (vrel - 8.86) ** 2 * vrel ** (-1.94)
45
+
46
+
47
+ def _worker(
48
+ inds, tind, Func3_1, Func3_2, low_lim, up_lim, Rlam23, costh, sinth, pbar, quad_abs_tol
49
+ ):
50
+ if pbar:
51
+ tqdm_bar = tqdm(
52
+ desc=f"computing electron density process {tind}",
53
+ total=len(inds),
54
+ position=tind,
55
+ )
56
+ f31 = np.empty(inds.shape, dtype=costh.dtype)
57
+ f32 = np.empty(inds.shape, dtype=costh.dtype)
58
+ for lind, ind in enumerate(inds):
59
+ f31[lind] = do_int_1_quad(low_lim[ind], up_lim[ind], Rlam23[ind], costh[ind], quad_abs_tol)
60
+ f32[lind] = do_int_2_quad(
61
+ low_lim[ind], up_lim[ind], Rlam23[ind], costh[ind], sinth[ind], quad_abs_tol
62
+ )
63
+ if pbar:
64
+ tqdm_bar.update(1)
65
+ with Func3_1.get_lock():
66
+ shared_f31 = np.ndarray(costh.shape, dtype=np.float64, buffer=Func3_1.get_obj())
67
+ shared_f31[inds] = f31
68
+ with Func3_2.get_lock():
69
+ shared_f32 = np.ndarray(costh.shape, dtype=np.float64, buffer=Func3_2.get_obj())
70
+ shared_f32[inds] = f32
71
+ if pbar:
72
+ tqdm_bar.close()
73
+
74
+
75
+ def _do_integral_1(ksi, rlam23, cth):
76
+ return (
77
+ np.sqrt(1 + 2 * rlam23 * ksi ** (2 / 3) / np.pi)
78
+ * np.exp(-3 * rlam23 * ksi ** (2 / 3) / 2)
79
+ * np.sqrt((ksi**2 - cth**2) / (1 - ksi**2))
80
+ )
81
+
82
+
83
+ def _do_integral_2(ksi, rlam23, cth, sth):
84
+ return (
85
+ np.sqrt(1 + 2 * rlam23 * ksi ** (2 / 3) / np.pi)
86
+ * np.exp(-3 * rlam23 * ksi ** (2 / 3) / 2)
87
+ * np.arcsin(np.sqrt(1 - ksi**2) * np.abs(cth) / (ksi * np.abs(sth)))
88
+ )
89
+
90
+
91
+ def do_int_1_quad(a, b, rlam23, cth, quad_abs_tol):
92
+ int1 = sci.quad(
93
+ _do_integral_1,
94
+ a,
95
+ b,
96
+ args=(rlam23, cth),
97
+ epsrel=0,
98
+ epsabs=quad_abs_tol,
99
+ )
100
+ return int1[0]
101
+
102
+
103
+ def do_int_2_quad(a, b, rlam23, cth, sth, quad_abs_tol):
104
+ int2 = sci.quad(
105
+ _do_integral_2,
106
+ a,
107
+ b,
108
+ args=(rlam23, cth, sth),
109
+ epsrel=0,
110
+ epsabs=quad_abs_tol,
111
+ )
112
+ return int2[0]
113
+
114
+
115
+ def plasma_distribution_morphology(
116
+ x_grid,
117
+ y_grid,
118
+ quad_abs_tol=1e-7,
119
+ upper_limit_delta=1e-6,
120
+ threads=None,
121
+ pbar=True,
122
+ ):
123
+ """The plasma distribution morphology component of the Dimant and Oppenheim model, that is
124
+ scaled by mean free path and independent of meteor parameters.
125
+
126
+ grid is in units of mean free path
127
+ """
128
+ # Generate density distribution
129
+ # Solve for a 2D plane
130
+ [Xlam, Ylam] = np.meshgrid(x_grid, y_grid)
131
+
132
+ Rlam = np.sqrt(Xlam**2 + Ylam**2)
133
+ Rlam[Rlam == 0] = np.nan
134
+
135
+ costh = Xlam / Rlam
136
+ sinth = Ylam / Rlam
137
+ Rlam23 = Rlam ** (2 / 3)
138
+
139
+ Func1 = np.sqrt(2 * np.pi / 3) / Rlam * scs.erf(
140
+ np.sqrt(3 / 2) * Rlam ** (1 / 3) * np.abs(costh) ** (1 / 3)
141
+ ) - np.exp(-3 / 2 * Rlam23 * np.abs(costh) ** (2 / 3)) * (
142
+ (4 - np.pi)
143
+ * np.abs(costh)
144
+ / (2 * np.sqrt((1 + (4 - np.pi) ** 2 * Rlam23 * np.abs(costh) ** (2 / 3)) / (2 * np.pi)))
145
+ + 2 * np.abs(costh) ** (1 / 3) / Rlam23
146
+ )
147
+
148
+ Func2 = np.sqrt(2 * np.pi / 3) / Rlam * scs.erf(np.sqrt(3 * Rlam23 / 2)) - (
149
+ 1 + 2 / Rlam23
150
+ ) * np.exp(-3 * Rlam23 / 2)
151
+
152
+ orig_shape = costh.shape
153
+ new_shape = (costh.size,)
154
+ costh = costh.reshape(new_shape)
155
+ sinth = sinth.reshape(new_shape)
156
+ Rlam23 = Rlam23.reshape(new_shape)
157
+ low_lim = np.abs(costh) # Lower limit of integration |cosθ|
158
+ up_lim = np.full_like(
159
+ costh, 1 - upper_limit_delta
160
+ ) # Upper limit of integration 1 (avoid singularity at exactly 1)
161
+
162
+ # todo: hack to make sure integration limits are sane
163
+ low_lim[low_lim > up_lim] = up_lim[low_lim > up_lim] - upper_limit_delta
164
+
165
+ # TODO: skip problematic points in the calc
166
+ if threads is None:
167
+ if pbar:
168
+ tqdm_bar = tqdm(desc="computing electron density", total=len(costh))
169
+ Func3_1 = np.empty_like(costh)
170
+ Func3_2 = np.empty_like(costh)
171
+ for ind in range(len(costh)):
172
+ Func3_1[ind] = do_int_1_quad(
173
+ low_lim[ind],
174
+ up_lim[ind],
175
+ Rlam23[ind],
176
+ costh[ind],
177
+ quad_abs_tol,
178
+ )
179
+ Func3_2[ind] = do_int_2_quad(
180
+ low_lim[ind],
181
+ up_lim[ind],
182
+ Rlam23[ind],
183
+ costh[ind],
184
+ sinth[ind],
185
+ quad_abs_tol,
186
+ )
187
+ if pbar:
188
+ tqdm_bar.update(1)
189
+ if pbar:
190
+ tqdm_bar.close()
191
+ else:
192
+ th = []
193
+ chunks = np.array_split(np.arange(len(costh)), threads)
194
+
195
+ f31 = mp.Array(ctypes.c_double, costh.size)
196
+ f32 = mp.Array(ctypes.c_double, costh.size)
197
+ for tind, chunk in enumerate(chunks):
198
+ t = mp.Process(
199
+ target=_worker,
200
+ args=(
201
+ chunk,
202
+ tind,
203
+ f31,
204
+ f32,
205
+ low_lim,
206
+ up_lim,
207
+ Rlam23,
208
+ costh,
209
+ sinth,
210
+ pbar,
211
+ quad_abs_tol,
212
+ ),
213
+ )
214
+ th.append(t)
215
+ t.start()
216
+
217
+ for t in th:
218
+ t.join()
219
+ print("\n" * threads)
220
+ Func3_1 = np.ndarray(costh.shape, dtype=np.float64, buffer=f31.get_obj())
221
+ Func3_2 = np.ndarray(costh.shape, dtype=np.float64, buffer=f32.get_obj())
222
+ Func3_1 = Func3_1.reshape(orig_shape)
223
+ Func3_2 = Func3_2.reshape(orig_shape)
224
+
225
+ costh = costh.reshape(orig_shape)
226
+ sinth = sinth.reshape(orig_shape)
227
+
228
+ t1 = np.abs(costh) * Func1
229
+ t2 = costh * Func2
230
+ t3 = np.abs(costh) * Func3_2 + Func3_1
231
+ t3[np.isnan(t3)] = 0
232
+
233
+ ne_morph = np.abs(t1 + t2 + t3)
234
+
235
+ # todo: this is to fix some numerical shenanigans, can probably be solved better
236
+ zero_point = np.argmin(np.abs(y_grid))
237
+ if y_grid[zero_point] < 1e-3:
238
+ ne_morph[zero_point, :] = (ne_morph[zero_point - 1, :] + ne_morph[zero_point + 1, :]) / 2
239
+
240
+ return Xlam, Ylam, Rlam, ne_morph
241
+
242
+
243
+ def plasma_distribution(
244
+ total_atmospheric_number_density,
245
+ meteoroid_velocity,
246
+ meteoroid_radius,
247
+ plasma_source_density,
248
+ collisional_cross_section,
249
+ ionization_probability,
250
+ base_plasma_distribution_function,
251
+ R_lambda,
252
+ ablated_thermal_speed=951,
253
+ atmospheric_species_mass=4.7e-26,
254
+ ablated_species_mass=3.8e-26,
255
+ ):
256
+ """Calculate meteor head plasma distribution using model from Dimant and
257
+ Oppenheim 2017
258
+
259
+ Originally developed by Tarnecki, Liane et al. 20 June 2020
260
+ Code available at https://zenodo.org/records/4723667
261
+ Paper available at https://doi.org/10.1029/2021JA029525
262
+ Ported to Python by Daniel Kastinen 2025
263
+
264
+ ablated_thermal_speed = 951 #thermal speed of ablated particles, m/s
265
+ # Set meteoroid and atmospheric characteristics
266
+ # atmospheric_species_mass = 4.7e-26 #atmospheric species mass (N2), kg
267
+ # ablated_species_mass = 3.8e-26 #ablated species mass (Na), kg
268
+ """
269
+
270
+ # mean free path
271
+ lamT = ablated_thermal_speed / (
272
+ total_atmospheric_number_density * collisional_cross_section * meteoroid_velocity
273
+ )
274
+
275
+ # Calculate the ionizing cross section Gion(meteoroid_velocity)
276
+ Gcoll = collisional_cross_section / (4 * np.pi) # net differential cross section
277
+ Gion = Gcoll * ionization_probability
278
+
279
+ coeff_const = (
280
+ 8
281
+ * np.pi
282
+ * meteoroid_radius**2
283
+ * plasma_source_density
284
+ * total_atmospheric_number_density
285
+ / np.sqrt(3)
286
+ * (1 + ablated_species_mass / atmospheric_species_mass)
287
+ * Gion
288
+ ) # Density coefficient
289
+
290
+ coeff = coeff_const / (R_lambda * lamT)
291
+
292
+ ne2D = coeff * base_plasma_distribution_function
293
+ return ne2D
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python
2
+
3
+ """Model implemented in:
4
+
5
+ Kero, J., Szasz, C., Pellinen-Wannberg, A., Wannberg, G., Westman, A., and Meisel, D. D. (2008).
6
+ Determination of meteoroid physical properties from tristatic radar observations.
7
+ Annales Geophysicae
8
+ """
9
+
10
+ import logging
11
+ from typing import Union
12
+ import numpy as np
13
+ import scipy
14
+ from scipy import constants
15
+ import xarray
16
+
17
+ from ..ode import ScipyODESolve
18
+ from .. import physics
19
+ from .. import coordinates
20
+ from ..atmosphere import AtmPymsis, AtmMSISE00
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class KeroSzasz2008(ScipyODESolve):
26
+ """Ablation model
27
+
28
+ Keyword arguments:
29
+
30
+ - temperature0 = 290 [K]: Meteoroid temperature at starting height
31
+ - shape_factor = 1.21 [1]: Shape is assumed to be a sphere.
32
+ - emissivity = 0.9 [1]: Hill et al.; Love & Brownlee: 1; (metal oxides)
33
+ - sputtering = True [bool]: If sputtering is used in mass loss calculation.
34
+ - Gamma = None [1]: Drag coefficient, if :code:`None` it is dynamically
35
+ calculated assuming a transition from (and including) free molecular
36
+ flow to a (and not including) shock regime. Otherwise assumed a
37
+ constant with the given value.
38
+ - Lambda = None [1]: Heat transfer coefficient, if :code:`None` it is
39
+ dynamically calculated assuming a transition from (and including)
40
+ free molecular flow to a (and not including) shock regime.
41
+ Otherwise assumed a constant with the given value.
42
+
43
+ """
44
+
45
+ DEFAULT_CONFIG = {
46
+ "options": {
47
+ "temperature0": 290,
48
+ "shape_factor": 1.21,
49
+ "emissivity": 0.9,
50
+ "sputtering": False,
51
+ "Gamma": None,
52
+ "Lambda": None,
53
+ "integral_resolution": 100,
54
+ },
55
+ "atmosphere": {
56
+ "f107": None,
57
+ "f107s": None,
58
+ "version": 2.1,
59
+ },
60
+ "integrate": {
61
+ "minimum_mass_kg": 1e-11,
62
+ "max_step_size_sec": 1e-1,
63
+ "max_time_sec": 100.0,
64
+ "method": "RK45",
65
+ },
66
+ "method_options": {},
67
+ }
68
+
69
+ def __init__(self, atmosphere: Union[AtmPymsis, AtmMSISE00], *args, **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self._G = constants.G
72
+ self._M = 5.9742e24 # [kg] mass of earth
73
+ self.atmosphere = atmosphere
74
+
75
+ def rhs(
76
+ self,
77
+ t,
78
+ mass,
79
+ y,
80
+ material_data,
81
+ Lambda,
82
+ Gamma,
83
+ epoch,
84
+ reference_ecef,
85
+ v_dir_ecef,
86
+ ):
87
+ """The right hand side of the differential equation to be integrated, i.e:
88
+
89
+ .. math::
90
+
91
+ \\frac{\\mathrm{d}v}{\\mathrm{d}t} = \\\\
92
+ \\\\
93
+ \\frac{\\mathrm{d}m}{\\mathrm{d}t} = \\\\
94
+ \\\\
95
+ \\frac{\\mathrm{d}s}{\\mathrm{d}t} = \\\\
96
+ \\\\
97
+ \\frac{\\mathrm{d}T}{\\mathrm{d}t} =
98
+
99
+
100
+ The numpy vector is structured as follows (numbers indicating index):
101
+
102
+ 0. dvdt
103
+ 1. dmdt
104
+ 2. dsdt
105
+ 3. dTdt
106
+
107
+ """
108
+ vel, s, T = y
109
+
110
+ # meteoroid height above earth surface
111
+ traj = reference_ecef - v_dir_ecef * s
112
+ lat, lon, alt = coordinates.ecef2geodetic(*traj.tolist())
113
+
114
+ ecef = coordinates.geodetic2ecef(lat, lon, alt)
115
+ r = np.linalg.norm(ecef)
116
+
117
+ # logger.debug(f'Position: {ecef*1e-3} km')
118
+ # logger.debug(f'lat = {lat}, lon = {lon}, {alt*1e-3} km')
119
+
120
+ # logger.debug(f't0 + {t} s: ')
121
+ # logger.debug(f'vel = {vel*1e-3} km/s, traj-s = {s*1e-3} km, mass = {mass} kg, temp = {T} K')
122
+ # logger.debug(f'altitude = {alt*1e-3} km')
123
+
124
+ f107 = self.config.get("atmosphere", "f107")
125
+ if f107 is not None:
126
+ f107 = float(f107)
127
+ f107s = self.config.get("atmosphere", "f107s")
128
+ if f107s is not None:
129
+ f107s = float(f107s)
130
+
131
+ atm = self.atmosphere.density(
132
+ time=epoch + np.timedelta64(int(t * 1e6), "us"),
133
+ lat=lat,
134
+ lon=lon,
135
+ alt=alt,
136
+ f107=f107,
137
+ f107s=f107s,
138
+ mass_densities=False,
139
+ version=self.config.getfloat("atmosphere", "version"),
140
+ )
141
+
142
+ rho_tot = atm["Total"].values.squeeze()
143
+ N_rho_tot = rho_tot / self.atmosphere.mean_mass
144
+
145
+ if self.config.getboolean("options", "sputtering"):
146
+ dmdt_s = physics.sputtering.sputtering(
147
+ mass=mass,
148
+ velocity=vel,
149
+ material_data=material_data,
150
+ density=atm,
151
+ )
152
+ else:
153
+ dmdt_s = 0.0
154
+
155
+ dmdt_a = physics.thermal_ablation.thermal_ablation_hill_et_al_2005(
156
+ mass=mass,
157
+ temperature=T,
158
+ material_data=material_data,
159
+ shape_factor=self.config.getfloat("options", "shape_factor"),
160
+ )
161
+
162
+ # logger.debug(f'dmdt sputtering: {dmdt_s} kg/s')
163
+ # logger.debug(f'dmdt ablation : {dmdt_a} kg/s')
164
+
165
+ if Lambda is None:
166
+ Lambda = physics.thermal_ablation.heat_transfer_bronshten_1983(
167
+ mass=mass,
168
+ velocity=vel,
169
+ temperature=T,
170
+ material_data=material_data,
171
+ atm_total_number_density=N_rho_tot,
172
+ mass_loss_thermal_ablation=dmdt_a,
173
+ atm_mean_mass=self.atmosphere.mean_mass,
174
+ res=self.config.getint("options", "integral_resolution"),
175
+ )
176
+
177
+ if Gamma is None:
178
+ Gamma = physics.thermal_ablation.drag_coefficient_bronshten_1983(
179
+ mass=mass,
180
+ velocity=vel,
181
+ temperature=T,
182
+ material_data=material_data,
183
+ atm_total_number_density=N_rho_tot,
184
+ atm_mean_mass=self.atmosphere.mean_mass,
185
+ res=self.config.getint("options", "integral_resolution"),
186
+ )
187
+
188
+ # -- Differential equation for the velocity to solve
189
+ dvdt_d = (
190
+ -Gamma
191
+ * self.config.getfloat("options", "shape_factor")
192
+ * rho_tot
193
+ * vel**2
194
+ / (mass ** (1.0 / 3.0) * material_data["rho_m"] ** (2.0 / 3.0))
195
+ ) # [m/s2] drag equation (because of conservation of linear momentum):
196
+ # decelearation=Drag_coeff*shape_factor*atm_dens*vel/(mass^1/2 * meteoroid_dens^2/3)
197
+ dvdt_g = self._G * self._M / (r**2) # [m/s2] acceleration due to earth gravitaion
198
+ dvdt = dvdt_d + dvdt_g
199
+
200
+ # -- Differential equation for the height to solve
201
+ dsdt = -vel # range from the common volume along the meteoroid trajectory
202
+
203
+ dTdt = physics.thermal_ablation.temperature_rate_hill_et_al_2005(
204
+ mass=mass,
205
+ velocity=vel,
206
+ temperature=T,
207
+ material_data=material_data,
208
+ shape_factor=self.config.getfloat("options", "shape_factor"),
209
+ atm_total_mass_density=rho_tot,
210
+ mass_loss_thermal_ablation=-dmdt_a,
211
+ Lambda=Lambda,
212
+ atm_temperature=self.config.getfloat("options", "temperature0"),
213
+ emissivity=self.config.getfloat("options", "emissivity"),
214
+ )
215
+
216
+ dmdt = dmdt_a + dmdt_s # total mass loss
217
+
218
+ ret = np.array([dmdt, dvdt, dsdt, dTdt], dtype=np.float64)
219
+
220
+ # logging.debug(
221
+ # f"DERIVS: vel = {dvdt*1e-3} km/s^2, traj-s = {dsdt*1e-3}"
222
+ # f"km/s, mass = {dmdt} kg/s, temp = {dTdt} K/s"
223
+ # )
224
+
225
+ return ret
226
+
227
+ def run(
228
+ self,
229
+ velocity0,
230
+ mass0,
231
+ altitude0,
232
+ zenith_ang,
233
+ azimuth_ang,
234
+ material_data,
235
+ time,
236
+ lat,
237
+ lon,
238
+ alt,
239
+ ):
240
+ """Runs the ablation model.
241
+
242
+ Parameters
243
+ ----------
244
+ velocity0 : float or numpy.ndarray
245
+ Meteoroid initial velocity [m/s]
246
+ mass0 : float or numpy.ndarray
247
+ Meteoroid initial mass [kg]
248
+ altitude0 : float or numpy.ndarray
249
+ Meteoroid initial altitude [m]
250
+ zenith_ang : float or numpy.ndarray
251
+ Zenith angle of the trajectory (-velocity vector) w.r.t reference point [deg]
252
+ azimuth_ang : float or numpy.ndarray
253
+ Azimuthal angle east of north of the trajectory (-velocity vector) w.r.t reference point [deg]
254
+ material_data : dict
255
+ Meteoroid material data, see [`material_data`][ablate.functions.material.material_data].
256
+ lat : float or numpy.ndarray
257
+ Geographic latitude in degrees of reference point on the meteoroid trajectory
258
+ lon : float or numpy.ndarray
259
+ Geographic longitude in degrees of reference point on the meteoroid trajectory
260
+ alt : float or numpy.ndarray
261
+ Altitude above geoid in meters of reference point on the meteoroid trajectory
262
+
263
+ Returns
264
+ -------
265
+ xarray.Dataset
266
+ mass, velocity, position, temperature, ecef_x, ecef_y, ecef_z, altitude as variables.
267
+
268
+ Notes
269
+ -----
270
+ This function is based on calc_sput.m which was used to verify the
271
+ sputtering described in Rogers et al.: Mass loss due to sputtering and
272
+ thermal processes in meteoroid ablation,
273
+ Planetary and Space Science 53 p. 1341-1354 (2005).
274
+
275
+ TODO: Add additional dynamical parameters to data structure e.g.
276
+ lambda and gamma if they are not constant
277
+
278
+ """
279
+ logger.debug(f"Running {self.__class__} model")
280
+
281
+ reference_ecef = coordinates.geodetic2ecef(lat, lon, alt)
282
+ v_dir = -1.0 * coordinates.azel_to_cart(azimuth_ang, zenith_ang, 1.0)
283
+ v_dir_ecef = coordinates.enu2ecef(lat, lon, alt, *v_dir.tolist())
284
+
285
+ def s_to_geo(s):
286
+ traj = reference_ecef - v_dir_ecef * s
287
+ geo = coordinates.ecef2geodetic(*traj.tolist())
288
+ return geo
289
+
290
+ s0 = scipy.optimize.minimize_scalar(lambda s: np.abs(s_to_geo(s)[2] - altitude0)).x
291
+ t0 = self.config.getfloat("options", "temperature0")
292
+ y0 = np.array(
293
+ [mass0, velocity0, s0, t0],
294
+ dtype=np.float64,
295
+ )
296
+
297
+ Lambda = self.config.get("options", "Lambda")
298
+ if Lambda is not None:
299
+ Lambda = float(Lambda)
300
+ Gamma = self.config.get("options", "Gamma")
301
+ if Gamma is not None:
302
+ Gamma = float(Gamma)
303
+
304
+ ivp_result = self.integrate(
305
+ y0,
306
+ material_data,
307
+ Lambda,
308
+ Gamma,
309
+ time,
310
+ reference_ecef,
311
+ v_dir_ecef,
312
+ )
313
+ t = ivp_result.t
314
+ _data = {}
315
+ variables = [
316
+ "mass",
317
+ "velocity",
318
+ "position",
319
+ "temperature",
320
+ "ecef_x",
321
+ "ecef_y",
322
+ "ecef_z",
323
+ "altitude",
324
+ ]
325
+ for key in variables:
326
+ _data[key] = (["t"], np.empty(t.shape, dtype=np.float64))
327
+
328
+ results = xarray.Dataset(
329
+ _data,
330
+ coords={"t": t},
331
+ attrs={key: val for key, val in self.config.items("options")},
332
+ )
333
+
334
+ results["mass"][:] = ivp_result.y[0, :]
335
+ results["velocity"][:] = ivp_result.y[1, :]
336
+ results["position"][:] = ivp_result.y[2, :]
337
+ results["temperature"][:] = ivp_result.y[3, :]
338
+
339
+ ecef = ivp_result.y[2, :]
340
+ ecef = reference_ecef[:, None] - v_dir_ecef[:, None] * ecef[None, :]
341
+ alts = np.array([s_to_geo(_s)[2] for _s in ivp_result.y[2, :]])
342
+
343
+ results["ecef_x"][:] = ecef[0, :]
344
+ results["ecef_y"][:] = ecef[1, :]
345
+ results["ecef_z"][:] = ecef[2, :]
346
+ results["altitude"][:] = alts
347
+
348
+ return results