pa3py 1.0.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.
- pa3py/__init__.py +65 -0
- pa3py/composition.py +117 -0
- pa3py/constants.py +19 -0
- pa3py/core.py +109 -0
- pa3py/data.py +122 -0
- pa3py/pebble_accretion.py +228 -0
- pa3py/plotting.py +164 -0
- pa3py/snowline.py +68 -0
- pa3py-1.0.0.dist-info/METADATA +99 -0
- pa3py-1.0.0.dist-info/RECORD +13 -0
- pa3py-1.0.0.dist-info/WHEEL +5 -0
- pa3py-1.0.0.dist-info/licenses/LICENSE +21 -0
- pa3py-1.0.0.dist-info/top_level.txt +1 -0
pa3py/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PA3Py - Pebble Accretion 3 in Python
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
|
|
7
|
+
from .data import load_tripodpy_hdf5, DiskData
|
|
8
|
+
from .composition import CompositionModel, SimpleWaterComposition, MultiSnowlineComposition, FunctionComposition
|
|
9
|
+
from .pebble_accretion import PebbleAccretionModule3
|
|
10
|
+
from .snowline import generate_rsnow_array, get_rsnow_from_mdot_au, mdot_time, r_snow_time_cgs
|
|
11
|
+
from .plotting import plot_hovmoller
|
|
12
|
+
from .core import PA3Py
|
|
13
|
+
from . import constants
|
|
14
|
+
|
|
15
|
+
def easy_run(data_dir: str, embryos_au: list, m_seed_me: float = 1e-4) -> dict:
|
|
16
|
+
"""
|
|
17
|
+
Ruta rápida para correr el simulador PA3Py con el modelo por defecto.
|
|
18
|
+
|
|
19
|
+
Asume:
|
|
20
|
+
- Snowline dinámica basada en Oka et al. (2011) y Hartmann.
|
|
21
|
+
- Composición: 100% Silicatos (dentro de snowline) y 50% H2O / 50% Silicatos (fuera).
|
|
22
|
+
|
|
23
|
+
Parámetros:
|
|
24
|
+
-----------
|
|
25
|
+
data_dir : str
|
|
26
|
+
Ruta al directorio con los HDF5 de TripodPy.
|
|
27
|
+
embryos_au : list
|
|
28
|
+
Lista con las posiciones iniciales de los embriones en AU (ej: [3.0, 5.0, 10.0]).
|
|
29
|
+
m_seed_me : float
|
|
30
|
+
Masa semilla inicial de los embriones en Masas Terrestres.
|
|
31
|
+
|
|
32
|
+
Retorna:
|
|
33
|
+
--------
|
|
34
|
+
dict
|
|
35
|
+
Diccionario con la historia de crecimiento de cada embrión.
|
|
36
|
+
"""
|
|
37
|
+
# 1. Cargar datos
|
|
38
|
+
disk = load_tripodpy_hdf5(data_dir)
|
|
39
|
+
|
|
40
|
+
# 2. Configurar snowline y composición clásica
|
|
41
|
+
rsnow = generate_rsnow_array(disk.times)
|
|
42
|
+
comp = SimpleWaterComposition(rsnow)
|
|
43
|
+
|
|
44
|
+
# 3. Inicializar simulador y correr
|
|
45
|
+
sim = PebbleAccretionModule3(disk, comp_model=comp)
|
|
46
|
+
results = sim.run_growth(embryos_au, M0_g=m_seed_me * sim.M_EARTH)
|
|
47
|
+
|
|
48
|
+
# 4. Mostrar resumen automáticamente
|
|
49
|
+
sim.summary(results)
|
|
50
|
+
return results
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"load_tripodpy_hdf5",
|
|
54
|
+
"DiskData",
|
|
55
|
+
"CompositionModel",
|
|
56
|
+
"SimpleWaterComposition",
|
|
57
|
+
"MultiSnowlineComposition",
|
|
58
|
+
"FunctionComposition",
|
|
59
|
+
"PebbleAccretionModule3",
|
|
60
|
+
"generate_rsnow_array",
|
|
61
|
+
"easy_run",
|
|
62
|
+
"plot_hovmoller",
|
|
63
|
+
"PA3Py",
|
|
64
|
+
"constants"
|
|
65
|
+
]
|
pa3py/composition.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
composition.py - Modelos de composición química.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional, Callable
|
|
6
|
+
import numpy as np
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
class CompositionModel(ABC):
|
|
10
|
+
"""Clase base para modelos de composición."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def get_fractions(self, r: float, t_sec: float, t_idx: int) -> Dict[str, float]:
|
|
14
|
+
"""
|
|
15
|
+
Dado un radio orbital `r` (en cm), el tiempo físico `t_sec` (s), y el índice temporal `t_idx`,
|
|
16
|
+
devuelve un diccionario con las fracciones de masa de cada elemento químico que se está acretando.
|
|
17
|
+
Las fracciones deben sumar 1.0.
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_species(self) -> List[str]:
|
|
23
|
+
"""Devuelve la lista de nombres de las especies químicas rastreadas."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimpleWaterComposition(CompositionModel):
|
|
28
|
+
"""
|
|
29
|
+
Modelo clásico PA3Py:
|
|
30
|
+
- 100% silicatos interior a la snowline.
|
|
31
|
+
- 50% silicatos, 50% H2O exterior a la snowline.
|
|
32
|
+
"""
|
|
33
|
+
def __init__(self, rsnow_h2o_array: np.ndarray):
|
|
34
|
+
self.rsnow = rsnow_h2o_array
|
|
35
|
+
|
|
36
|
+
def get_fractions(self, r: float, t_sec: float, t_idx: int) -> Dict[str, float]:
|
|
37
|
+
r_snow = float(self.rsnow[t_idx]) if not np.isnan(self.rsnow[t_idx]) else 0.0
|
|
38
|
+
|
|
39
|
+
if r >= r_snow:
|
|
40
|
+
return {"silicates": 0.5, "H2O": 0.5}
|
|
41
|
+
else:
|
|
42
|
+
return {"silicates": 1.0, "H2O": 0.0}
|
|
43
|
+
|
|
44
|
+
def get_species(self) -> List[str]:
|
|
45
|
+
return ["silicates", "H2O"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MultiSnowlineComposition(CompositionModel):
|
|
49
|
+
"""Zonas estáticas separadas por snowlines pre-calculadas."""
|
|
50
|
+
def __init__(self, snowlines: Dict[str, np.ndarray], zone_abundances: List[Dict[str, float]]):
|
|
51
|
+
self.snowline_names = list(snowlines.keys())
|
|
52
|
+
self.snowlines = snowlines
|
|
53
|
+
self.zone_abundances = zone_abundances
|
|
54
|
+
|
|
55
|
+
if len(zone_abundances) != len(snowlines) + 1:
|
|
56
|
+
raise ValueError("Debes proveer exactamente N+1 diccionarios de abundancias para N snowlines.")
|
|
57
|
+
|
|
58
|
+
self.species = list(set().union(*(abund.keys() for abund in self.zone_abundances)))
|
|
59
|
+
|
|
60
|
+
def get_fractions(self, r: float, t_sec: float, t_idx: int) -> Dict[str, float]:
|
|
61
|
+
zone_idx = 0
|
|
62
|
+
for name in self.snowline_names:
|
|
63
|
+
r_snow = float(self.snowlines[name][t_idx]) if not np.isnan(self.snowlines[name][t_idx]) else 0.0
|
|
64
|
+
if r >= r_snow:
|
|
65
|
+
zone_idx += 1
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
raw_fracs = self.zone_abundances[zone_idx]
|
|
70
|
+
total = sum(raw_fracs.values())
|
|
71
|
+
if total > 0:
|
|
72
|
+
return {k: v / total for k, v in raw_fracs.items()}
|
|
73
|
+
return {sp: 0.0 for sp in self.species}
|
|
74
|
+
|
|
75
|
+
def get_species(self) -> List[str]:
|
|
76
|
+
return self.species
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FunctionComposition(CompositionModel):
|
|
80
|
+
"""Química dinámica definida por el usuario mediante una función Python."""
|
|
81
|
+
def __init__(self, user_func: Callable[[float, float], Dict[str, float]], species: Optional[List[str]] = None):
|
|
82
|
+
"""
|
|
83
|
+
Parámetros:
|
|
84
|
+
-----------
|
|
85
|
+
user_func: callable
|
|
86
|
+
Función del usuario. Debe recibir `r` (en cm) y `t` (en segundos)
|
|
87
|
+
y retornar un diccionario con las fracciones, ej: {"silicates": 0.4, "H2O": 0.6}.
|
|
88
|
+
species: List[str], opcional
|
|
89
|
+
Lista explícita de las especies químicas. Si no se provee, PA3Py la deducirá
|
|
90
|
+
automáticamente ejecutando la función con un valor de prueba (r=1.0, t=0.0).
|
|
91
|
+
"""
|
|
92
|
+
self.user_func = user_func
|
|
93
|
+
|
|
94
|
+
if species is None:
|
|
95
|
+
try:
|
|
96
|
+
res_sets = [set(self.user_func(r, t).keys())
|
|
97
|
+
for r in [1e11, 1e12, 1e13, 1e14, 1e15, 1e16]
|
|
98
|
+
for t in [0.0, 1e10, 1e12, 1e14]]
|
|
99
|
+
self.species = list(set.union(*res_sets)) if res_sets else []
|
|
100
|
+
if not self.species:
|
|
101
|
+
raise ValueError("La función devolvió diccionarios vacíos en todos los tests.")
|
|
102
|
+
except AttributeError:
|
|
103
|
+
raise ValueError("La función debe retornar un dict (con el método .keys()).")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise RuntimeError(f"Fallo en la auto-detección de especies: {e}\nProvee 'species' explícitamente.")
|
|
106
|
+
else:
|
|
107
|
+
self.species = species
|
|
108
|
+
|
|
109
|
+
def get_fractions(self, r: float, t_sec: float, t_idx: int) -> Dict[str, float]:
|
|
110
|
+
raw_fracs = self.user_func(r, t_sec)
|
|
111
|
+
total = sum(raw_fracs.values())
|
|
112
|
+
if total > 0:
|
|
113
|
+
return {sp: raw_fracs.get(sp, 0.0) / total for sp in self.species}
|
|
114
|
+
return {sp: 0.0 for sp in self.species}
|
|
115
|
+
|
|
116
|
+
def get_species(self) -> List[str]:
|
|
117
|
+
return self.species
|
pa3py/constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants used for Pebble Accretion calculations in PA3Py.
|
|
3
|
+
All values are in CGS units (cm, g, s).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Gravitational constant [cm^3 g^-1 s^-2]
|
|
7
|
+
G = 6.6743e-8
|
|
8
|
+
|
|
9
|
+
# Solar mass [g]
|
|
10
|
+
M_SUN = 1.988409870698051e33
|
|
11
|
+
|
|
12
|
+
# Earth mass [g]
|
|
13
|
+
M_EARTH = 5.972167867791379e27
|
|
14
|
+
|
|
15
|
+
# Astronomical Unit [cm]
|
|
16
|
+
AU = 1.495978707e13
|
|
17
|
+
|
|
18
|
+
# Julian Year [s]
|
|
19
|
+
YEAR = 3.15576e7
|
pa3py/core.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.py - Interfaz principal (Facade).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Callable, Optional
|
|
6
|
+
import h5py
|
|
7
|
+
|
|
8
|
+
from .data import load_tripodpy_hdf5
|
|
9
|
+
from .composition import CompositionModel, SimpleWaterComposition, FunctionComposition
|
|
10
|
+
from .pebble_accretion import PebbleAccretionModule3
|
|
11
|
+
from .snowline import generate_rsnow_array
|
|
12
|
+
from .plotting import plot_hovmoller
|
|
13
|
+
from . import constants as c
|
|
14
|
+
|
|
15
|
+
class PA3Py:
|
|
16
|
+
"""
|
|
17
|
+
Centraliza flujo de PA3Py: Datos HDF5, química, acreción y gráficas.
|
|
18
|
+
"""
|
|
19
|
+
def __init__(self, data_dir: str, comp_model: Optional[CompositionModel] = None):
|
|
20
|
+
"""
|
|
21
|
+
Inicializa la simulación y carga los datos.
|
|
22
|
+
|
|
23
|
+
Parámetros:
|
|
24
|
+
-----------
|
|
25
|
+
data_dir : str
|
|
26
|
+
Ruta a archivos HDF5.
|
|
27
|
+
comp_model : CompositionModel, opcional
|
|
28
|
+
Modelo de composición. Por defecto: Agua simple + migración snowline.
|
|
29
|
+
"""
|
|
30
|
+
self.data_dir = data_dir
|
|
31
|
+
self.disk = load_tripodpy_hdf5(data_dir)
|
|
32
|
+
self.comp = comp_model or SimpleWaterComposition(generate_rsnow_array(self.disk.times))
|
|
33
|
+
self.engine = PebbleAccretionModule3(self.disk, comp_model=self.comp)
|
|
34
|
+
|
|
35
|
+
def set_custom_chemistry(self, user_func: Callable, species: Optional[List[str]] = None):
|
|
36
|
+
"""
|
|
37
|
+
Atajo para redefinir la química con una función Python.
|
|
38
|
+
|
|
39
|
+
Ejemplo:
|
|
40
|
+
sim.set_custom_chemistry(mi_funcion, ["silicatos", "H2O"])
|
|
41
|
+
"""
|
|
42
|
+
self.comp = FunctionComposition(user_func, species)
|
|
43
|
+
self.engine = PebbleAccretionModule3(self.disk, comp_model=self.comp)
|
|
44
|
+
|
|
45
|
+
def run_growth(self, embryos_au: list, m_seed_me: float = 1e-3) -> dict:
|
|
46
|
+
"""
|
|
47
|
+
Corre la simulación de acreción para los embriones dados.
|
|
48
|
+
|
|
49
|
+
Parámetros:
|
|
50
|
+
-----------
|
|
51
|
+
embryos_au : list
|
|
52
|
+
Lista de radios iniciales en AU (ej: [1.0, 5.0, 15.0]).
|
|
53
|
+
m_seed_me : float
|
|
54
|
+
Masa semilla en Masas Terrestres. Default: 1e-3.
|
|
55
|
+
|
|
56
|
+
Retorna:
|
|
57
|
+
--------
|
|
58
|
+
dict
|
|
59
|
+
Resultados de la evolución de masa en el tiempo.
|
|
60
|
+
"""
|
|
61
|
+
results = self.engine.run_growth(embryos_au, M0_g=m_seed_me * c.M_EARTH)
|
|
62
|
+
self.engine.summary(results)
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
def plot_hovmoller(self, field: str = 'dust_Sigma', show_snowlines: bool = True, **kwargs):
|
|
66
|
+
"""
|
|
67
|
+
Genera el diagrama de Hovmöller (Radio vs Tiempo).
|
|
68
|
+
field puede ser 'dust_Sigma', 'gas_Sigma', o 'epsilon'.
|
|
69
|
+
"""
|
|
70
|
+
return plot_hovmoller(self.disk, field=field, show_snowlines=show_snowlines, **kwargs)
|
|
71
|
+
|
|
72
|
+
def calculate_isolation_mass_map(self):
|
|
73
|
+
"""Calcula el mapa teórico de masa de aislamiento en todo el disco."""
|
|
74
|
+
return self.engine.calculate_isolation_mass_map()
|
|
75
|
+
|
|
76
|
+
def plot_population(self, results: dict, **kwargs):
|
|
77
|
+
"""
|
|
78
|
+
Grafica la población sintética de planetas (Masa Final vs Posición Inicial).
|
|
79
|
+
"""
|
|
80
|
+
from .plotting import plot_population
|
|
81
|
+
M_iso_map = self.calculate_isolation_mass_map()
|
|
82
|
+
return plot_population(self.disk, results, M_iso_map=M_iso_map, **kwargs)
|
|
83
|
+
|
|
84
|
+
def save_results(self, results: dict, filename: str):
|
|
85
|
+
"""Guarda tracks de crecimiento en HDF5."""
|
|
86
|
+
with h5py.File(filename, 'w') as f:
|
|
87
|
+
# Encode species como bytes para compatibilidad h5py
|
|
88
|
+
f.attrs['tracked_species'] = [s.encode('ascii') for s in self.engine.tracked_species]
|
|
89
|
+
f.attrs['M_EARTH'] = c.M_EARTH
|
|
90
|
+
|
|
91
|
+
tracks = f.create_group('tracks')
|
|
92
|
+
for r_au, track_data in results.items():
|
|
93
|
+
dset = tracks.create_dataset(f"embryo_{r_au:.4f}_AU", data=track_data)
|
|
94
|
+
dset.attrs['r_au'] = float(r_au)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def load_results(filename: str) -> tuple[dict, list]:
|
|
98
|
+
"""Carga tracks de crecimiento desde HDF5. Retorna (results_dict, species_list)."""
|
|
99
|
+
results = {}
|
|
100
|
+
with h5py.File(filename, 'r') as f:
|
|
101
|
+
raw_species = list(f.attrs['tracked_species'])
|
|
102
|
+
tracked_species = [s.decode('ascii') if isinstance(s, bytes) else s for s in raw_species]
|
|
103
|
+
|
|
104
|
+
tracks = f['tracks']
|
|
105
|
+
for key in tracks.keys():
|
|
106
|
+
r_au = float(tracks[key].attrs['r_au'])
|
|
107
|
+
results[r_au] = tracks[key][:]
|
|
108
|
+
|
|
109
|
+
return results, tracked_species
|
pa3py/data.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
data.py - Módulo para la carga y manejo de datos del disco protoplanetario.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import glob
|
|
7
|
+
import h5py
|
|
8
|
+
import numpy as np
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Dict
|
|
11
|
+
from . import constants as c
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DiskData:
|
|
15
|
+
"""Contenedor de propiedades físicas del disco (gas y polvo)."""
|
|
16
|
+
times: np.ndarray # (Nt,) Tiempos en segundos
|
|
17
|
+
r: np.ndarray # (Nr,) Grilla radial en cm
|
|
18
|
+
|
|
19
|
+
# Gas: shape (Nt, Nr)
|
|
20
|
+
gas_Sigma: np.ndarray # Densidad superficial del gas
|
|
21
|
+
gas_T: np.ndarray # Temperatura del gas
|
|
22
|
+
gas_cs: np.ndarray # Velocidad del sonido
|
|
23
|
+
gas_eta: np.ndarray # Gradiente de presión
|
|
24
|
+
gas_nu: np.ndarray # Viscosidad cinemática
|
|
25
|
+
gas_alpha: np.ndarray # Parámetro alpha de Shakura-Sunyaev
|
|
26
|
+
gas_Hp: np.ndarray # Escala de altura del gas
|
|
27
|
+
|
|
28
|
+
# Dust: shape (Nt, Nr, Nd) donde Nd=5 (o Nd=1 si ya se colapsó)
|
|
29
|
+
dust_Sigma: np.ndarray # Densidad superficial del polvo
|
|
30
|
+
dust_vr: np.ndarray # Velocidad radial del polvo
|
|
31
|
+
dust_St: np.ndarray # Número de Stokes
|
|
32
|
+
dust_H: np.ndarray # Escala de altura del polvo
|
|
33
|
+
|
|
34
|
+
# Derivados / Auxiliares
|
|
35
|
+
Omega_K: np.ndarray # Frecuencia kepleriana local (Nt, Nr) o (Nr,)
|
|
36
|
+
M_star: float # Masa estelar en masas solares
|
|
37
|
+
|
|
38
|
+
# Snowlines pre-calculadas presentes en el HDF5 (Opcional, útil para fallback)
|
|
39
|
+
# Formato: {"H2O": array(Nt,)}
|
|
40
|
+
hdf5_snowlines: Dict[str, np.ndarray]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def Nt(self):
|
|
44
|
+
return len(self.times)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def Nr(self):
|
|
48
|
+
return len(self.r)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_tripodpy_hdf5(datadir: str, M_star: float = 1.0, t_min_yr: float = 0.0) -> DiskData:
|
|
52
|
+
"""Convierte archivos HDF5 de tripodpy a un objeto DiskData."""
|
|
53
|
+
|
|
54
|
+
files = sorted(glob.glob(os.path.join(datadir, 'data*.hdf5')))
|
|
55
|
+
if not files:
|
|
56
|
+
raise FileNotFoundError(f"No HDF5 files found in {datadir}")
|
|
57
|
+
|
|
58
|
+
print(f"[load_tripodpy_hdf5] Reading {len(files)} snapshots from {datadir}...")
|
|
59
|
+
|
|
60
|
+
times_list, OmegaK_list = [], []
|
|
61
|
+
rsnow = {'H2O': []}
|
|
62
|
+
r_grid = None
|
|
63
|
+
|
|
64
|
+
gas_keys = ['Sigma', 'T', 'cs', 'eta', 'nu', 'alpha', 'Hp']
|
|
65
|
+
dust_keys = ['Sigma', 'v/rad', 'St', 'H']
|
|
66
|
+
gas_data = {k: [] for k in gas_keys}
|
|
67
|
+
dust_data = {k: [] for k in dust_keys}
|
|
68
|
+
|
|
69
|
+
for fpath in files:
|
|
70
|
+
with h5py.File(fpath, 'r') as f:
|
|
71
|
+
t_s = float(f['t'][()])
|
|
72
|
+
if t_s < t_min_yr * c.YEAR:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
times_list.append(t_s)
|
|
76
|
+
|
|
77
|
+
if r_grid is None:
|
|
78
|
+
r_grid = f['grid/r'][:]
|
|
79
|
+
|
|
80
|
+
# Cargar arrays
|
|
81
|
+
for k in gas_keys: gas_data[k].append(f[f'gas/{k}'][:])
|
|
82
|
+
for k in dust_keys: dust_data[k].append(f[f'dust/{k}'][:])
|
|
83
|
+
|
|
84
|
+
# OmegaK
|
|
85
|
+
if 'grid/OmegaK' in f:
|
|
86
|
+
OmegaK_list.append(f['grid/OmegaK'][:])
|
|
87
|
+
|
|
88
|
+
# Snowline dinámica de H2O original de la corrida
|
|
89
|
+
if 'dust/r_snow' in f:
|
|
90
|
+
rsnow['H2O'].append(float(f['dust/r_snow'][()]))
|
|
91
|
+
else:
|
|
92
|
+
rsnow_key = 'grid/rsnow_H2O'
|
|
93
|
+
rsnow['H2O'].append(float(f[rsnow_key][()]) if rsnow_key in f else np.nan)
|
|
94
|
+
|
|
95
|
+
times = np.array(times_list)
|
|
96
|
+
|
|
97
|
+
# OmegaK: 2D del HDF5 o analítico 1D
|
|
98
|
+
if OmegaK_list:
|
|
99
|
+
Omega_K = np.array(OmegaK_list)
|
|
100
|
+
else:
|
|
101
|
+
Omega_K = np.sqrt(c.G * M_star * c.M_SUN / r_grid**3)
|
|
102
|
+
|
|
103
|
+
rsnow['H2O'] = np.array(rsnow['H2O'])
|
|
104
|
+
|
|
105
|
+
return DiskData(
|
|
106
|
+
times=times,
|
|
107
|
+
r=r_grid,
|
|
108
|
+
gas_Sigma=np.array(gas_data['Sigma']),
|
|
109
|
+
gas_T=np.array(gas_data['T']),
|
|
110
|
+
gas_cs=np.array(gas_data['cs']),
|
|
111
|
+
gas_eta=np.array(gas_data['eta']),
|
|
112
|
+
gas_nu=np.array(gas_data['nu']),
|
|
113
|
+
gas_alpha=np.array(gas_data['alpha']),
|
|
114
|
+
gas_Hp=np.array(gas_data['Hp']),
|
|
115
|
+
dust_Sigma=np.array(dust_data['Sigma']),
|
|
116
|
+
dust_vr=np.array(dust_data['v/rad']),
|
|
117
|
+
dust_St=np.array(dust_data['St']),
|
|
118
|
+
dust_H=np.array(dust_data['H']),
|
|
119
|
+
Omega_K=Omega_K,
|
|
120
|
+
M_star=M_star,
|
|
121
|
+
hdf5_snowlines=rsnow
|
|
122
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PA3Py/pebble_accretion.py — Módulo de acreción de pebbles (Ormel 2017 & Drążkowska et al. 2023)
|
|
3
|
+
=====================================================================================
|
|
4
|
+
|
|
5
|
+
Física implementada y referencias:
|
|
6
|
+
- M_PA_onset = St * η³ * M_star (Drążkowska et al. 2023 Eq. 3, Ormel 2017 Eq 7.11)
|
|
7
|
+
- M_hw/sh = v_hw³ / (8 G Ω_K t_stop) (Ormel 2017 Eq 7.9)
|
|
8
|
+
- Ṁ_2D_hw = √(8GM t_stop v_hw) Σ_peb (Ormel 2017 Eq 7.13 Headwind)
|
|
9
|
+
- Ṁ_2D_sh = 2 R_H² Ω_K St^(2/3) Σ_peb (Ormel 2017 Eq 7.13 Shear, Drążkowska et al. 2023 Eq 5)
|
|
10
|
+
- Ṁ_3D = 2π G M t_stop ρ_peb (Ormel 2017 Eq 7.12)
|
|
11
|
+
- Transición = Ṁ_2D * b_col / (b_col + H_peb √(8/π)) (Ormel 2017 Eq 7.24)
|
|
12
|
+
- M_iso_peb = 25 M⊕ (H_gas/r / 0.05)³ (M_star/M_sun) (Drążkowska et al. 2023 Eq 6)
|
|
13
|
+
- M < M_onset: Acreción de Safronov Balística (Ormel 2017 Eq 7.14)
|
|
14
|
+
|
|
15
|
+
Unidades internas: CGS (g, cm, s).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .data import DiskData
|
|
21
|
+
from .composition import CompositionModel, SimpleWaterComposition
|
|
22
|
+
from . import constants as c
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PebbleAccretionModule3:
|
|
26
|
+
"""Motor físico de acreción agnóstico a HDF5 (Ormel 2017 & Drążkowska 2023)."""
|
|
27
|
+
|
|
28
|
+
# Índice único de pebbles. (El último bin de polvo representa los pebbles)
|
|
29
|
+
peb_idx = -1
|
|
30
|
+
|
|
31
|
+
def __init__(self, disk_data: DiskData, comp_model: CompositionModel = None):
|
|
32
|
+
"""
|
|
33
|
+
Inicializa el simulador con los datos del disco y un modelo de composición.
|
|
34
|
+
|
|
35
|
+
Parámetros:
|
|
36
|
+
-----------
|
|
37
|
+
disk_data : DiskData
|
|
38
|
+
Contenedor con todas las propiedades radiales y temporales del disco.
|
|
39
|
+
comp_model : CompositionModel, opcional
|
|
40
|
+
Modelo que define las snowlines y abundancias por regiones.
|
|
41
|
+
Si no se provee, se usará el modelo clásico de PA3Py (SimpleWaterComposition)
|
|
42
|
+
usando la snowline de agua que venga del HDF5 (si existe).
|
|
43
|
+
"""
|
|
44
|
+
self.data = disk_data
|
|
45
|
+
if comp_model is None:
|
|
46
|
+
rsnow_h2o = self.data.hdf5_snowlines.get('H2O', np.zeros(self.data.Nt))
|
|
47
|
+
self.comp = SimpleWaterComposition(rsnow_h2o)
|
|
48
|
+
else:
|
|
49
|
+
self.comp = comp_model
|
|
50
|
+
self.tracked_species = self.comp.get_species()
|
|
51
|
+
|
|
52
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
53
|
+
# Helpers Interp
|
|
54
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
def _interp(self, field_1d: np.ndarray, r_emb: float) -> float:
|
|
57
|
+
"""
|
|
58
|
+
Interpola logarítmicamente un campo radial 1D.
|
|
59
|
+
"""
|
|
60
|
+
return float(np.interp(np.log(r_emb), np.log(self.data.r), field_1d))
|
|
61
|
+
|
|
62
|
+
def _local(self, t: int, r_emb: float) -> dict:
|
|
63
|
+
"""Extrae e interpola las propiedades locales del disco."""
|
|
64
|
+
I = lambda arr: self._interp(arr[t], r_emb)
|
|
65
|
+
|
|
66
|
+
Sigma_peb = self._interp(self.data.dust_Sigma[t, :, self.peb_idx], r_emb)
|
|
67
|
+
eta = I(self.data.gas_eta)
|
|
68
|
+
St = self._interp(self.data.dust_St[t, :, self.peb_idx], r_emb)
|
|
69
|
+
H_gas = I(self.data.gas_Hp)
|
|
70
|
+
H_peb = self._interp(self.data.dust_H[t, :, self.peb_idx], r_emb)
|
|
71
|
+
|
|
72
|
+
if self.data.Omega_K.ndim == 2:
|
|
73
|
+
omega_1d = self.data.Omega_K[t]
|
|
74
|
+
else:
|
|
75
|
+
omega_1d = self.data.Omega_K
|
|
76
|
+
|
|
77
|
+
Omega = float(np.interp(np.log(r_emb), np.log(self.data.r), omega_1d))
|
|
78
|
+
v_K = Omega * r_emb
|
|
79
|
+
v_hw = eta * v_K
|
|
80
|
+
|
|
81
|
+
return dict(
|
|
82
|
+
Sigma_peb=Sigma_peb, eta=eta, St=St, H_gas=H_gas, H_peb=H_peb,
|
|
83
|
+
Omega=Omega, v_K=v_K, v_hw=v_hw
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
87
|
+
# Física Ormel 2017 & Drążkowska et al. 2023
|
|
88
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
89
|
+
|
|
90
|
+
def _pebble_flux(self, t: int, r_emb: float) -> float:
|
|
91
|
+
"""Ṁ_peb = 2π r Σ_peb |v_r|"""
|
|
92
|
+
Sigma_peb = self._interp(self.data.dust_Sigma[t, :, self.peb_idx], r_emb)
|
|
93
|
+
v_r = self._interp(self.data.dust_vr[t, :, self.peb_idx], r_emb)
|
|
94
|
+
return 2 * np.pi * r_emb * Sigma_peb * abs(v_r)
|
|
95
|
+
|
|
96
|
+
def _isolation_mass(self, r_emb: float, t: int) -> float:
|
|
97
|
+
"""Drążkowska 2023 Eq.6: M_iso = 25 M⊕ (h/0.05)³ (M★/M☉)"""
|
|
98
|
+
h_gas = self._interp(self.data.gas_Hp[t], r_emb) / r_emb
|
|
99
|
+
M_iso = 25 * (h_gas / 0.05)**3 * self.data.M_star * c.M_EARTH
|
|
100
|
+
return max(M_iso, 0.1 * c.M_EARTH)
|
|
101
|
+
|
|
102
|
+
def _accretion_rate(self, M_core: float, r_emb: float, t: int) -> float:
|
|
103
|
+
if M_core <= 0: return 0.0
|
|
104
|
+
p = self._local(t, r_emb)
|
|
105
|
+
if p['Sigma_peb'] < 1e-30: return 0.0
|
|
106
|
+
|
|
107
|
+
G, M, Omega = c.G, M_core, p['Omega']
|
|
108
|
+
St, v_hw, Sigma = p['St'], p['v_hw'], p['Sigma_peb']
|
|
109
|
+
|
|
110
|
+
t_stop = St / Omega
|
|
111
|
+
M_PA_onset = St * (p['eta']**3) * (self.data.M_star * c.M_SUN)
|
|
112
|
+
H_peb = max(p['H_peb'], 1e-10 * p['H_gas'])
|
|
113
|
+
rho_peb = Sigma / (np.sqrt(2 * np.pi) * H_peb)
|
|
114
|
+
|
|
115
|
+
# Safronov (Balístico)
|
|
116
|
+
if M < M_PA_onset:
|
|
117
|
+
rho_core = 3.0 # g/cm3
|
|
118
|
+
R_pl = (3 * M / (4 * np.pi * rho_core))**(1/3)
|
|
119
|
+
v_impact = max(v_hw, 1.0)
|
|
120
|
+
return (2 * np.pi * R_pl * G * M / v_impact) * rho_peb
|
|
121
|
+
|
|
122
|
+
# Transición Headwind/Shear
|
|
123
|
+
M_hw_sh = (v_hw**3) / (8 * G * Omega * St)
|
|
124
|
+
|
|
125
|
+
# Regímenes 2D
|
|
126
|
+
if M < M_hw_sh:
|
|
127
|
+
Mdot_2D = np.sqrt(8 * G * M * t_stop * v_hw) * Sigma
|
|
128
|
+
b_col = np.sqrt(2 * G * M * t_stop / max(v_hw, 1e-5))
|
|
129
|
+
else:
|
|
130
|
+
R_H = r_emb * (M / (3 * self.data.M_star * c.M_SUN))**(1/3)
|
|
131
|
+
Mdot_2D = 2 * R_H**2 * Omega * St**(2/3) * Sigma
|
|
132
|
+
b_col = (St**(1/3)) * R_H
|
|
133
|
+
|
|
134
|
+
# Transición 2D-3D turbulencia suave
|
|
135
|
+
denominator = b_col + H_peb * np.sqrt(8.0 / np.pi)
|
|
136
|
+
Mdot_eff = Mdot_2D * (b_col / denominator)
|
|
137
|
+
|
|
138
|
+
return max(Mdot_eff, 0.0)
|
|
139
|
+
|
|
140
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
141
|
+
# API / Loop Principal
|
|
142
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
def run_growth(self, embryo_locations_AU: list, M0_g: float = None) -> dict:
|
|
145
|
+
r_min_au, r_max_au = self.data.r[0] / c.AU, self.data.r[-1] / c.AU
|
|
146
|
+
for r_au in embryo_locations_AU:
|
|
147
|
+
if not (r_min_au <= r_au <= r_max_au):
|
|
148
|
+
raise ValueError(f"El embrión en {r_au:.2f} AU fuera del disco ({r_min_au:.2f}–{r_max_au:.2f} AU).")
|
|
149
|
+
|
|
150
|
+
if M0_g is None:
|
|
151
|
+
M0_g = 1e-3 * c.M_EARTH
|
|
152
|
+
|
|
153
|
+
locs_outer_to_inner = sorted(embryo_locations_AU, reverse=True)
|
|
154
|
+
M_core = {r: float(M0_g) for r in locs_outer_to_inner}
|
|
155
|
+
primary_rock = "silicates" if "silicates" in self.tracked_species else self.tracked_species[0]
|
|
156
|
+
|
|
157
|
+
M_X = {r: {sp: float(M0_g) if sp == primary_rock else 0.0 for sp in self.tracked_species} for r in locs_outer_to_inner}
|
|
158
|
+
|
|
159
|
+
histories= {r: [] for r in locs_outer_to_inner}
|
|
160
|
+
|
|
161
|
+
for i in range(self.data.Nt):
|
|
162
|
+
dt = float(self.data.times[i] - (self.data.times[i-1] if i > 0 else 0.0))
|
|
163
|
+
if dt <= 0: continue
|
|
164
|
+
|
|
165
|
+
flux_consumed = 0.0
|
|
166
|
+
|
|
167
|
+
for r_au in locs_outer_to_inner:
|
|
168
|
+
r_emb = r_au * c.AU
|
|
169
|
+
|
|
170
|
+
M_iso = self._isolation_mass(r_emb, i)
|
|
171
|
+
|
|
172
|
+
if M_core[r_au] < M_iso:
|
|
173
|
+
Mdot_peb_disk = self._pebble_flux(i, r_emb)
|
|
174
|
+
Mdot_peb_avail = max(0.0, Mdot_peb_disk - flux_consumed)
|
|
175
|
+
|
|
176
|
+
Mdot_core_r = self._accretion_rate(M_core[r_au], r_emb, i)
|
|
177
|
+
Mdot_core_r = min(Mdot_core_r, Mdot_peb_avail)
|
|
178
|
+
|
|
179
|
+
dM = Mdot_core_r * dt
|
|
180
|
+
dM = min(dM, max(0.0, M_iso - M_core[r_au]))
|
|
181
|
+
flux_consumed += Mdot_core_r
|
|
182
|
+
|
|
183
|
+
fractions = self.comp.get_fractions(r_emb, self.data.times[i], i)
|
|
184
|
+
for sp in self.tracked_species:
|
|
185
|
+
M_X[r_au][sp] += fractions.get(sp, 0.0) * dM
|
|
186
|
+
|
|
187
|
+
M_core[r_au] += dM
|
|
188
|
+
|
|
189
|
+
histories[r_au].append([self.data.times[i], M_core[r_au], M_iso] + [M_X[r_au].get(sp, 0.0) for sp in self.tracked_species])
|
|
190
|
+
|
|
191
|
+
results = {
|
|
192
|
+
r_au: (np.array(histories[r_au]) if histories[r_au] else np.empty((0, 3 + len(self.tracked_species))))
|
|
193
|
+
for r_au in embryo_locations_AU
|
|
194
|
+
}
|
|
195
|
+
return results
|
|
196
|
+
|
|
197
|
+
def summary(self, results: dict):
|
|
198
|
+
"""Imprime tabla resumen de la composición final."""
|
|
199
|
+
col_widths = [max(10, len(f"f_{sp}[%]") + 2) for sp in self.tracked_species]
|
|
200
|
+
SEP = '-' * (35 + sum(col_widths))
|
|
201
|
+
print(f"\n{SEP}")
|
|
202
|
+
header = f"{'r [AU]':>8} {'M_tot [ME]':>11} {'M_iso [ME]':>11}"
|
|
203
|
+
header += "".join(f"{f'f_{sp}[%]':>{w}}" for sp, w in zip(self.tracked_species, col_widths))
|
|
204
|
+
print(header)
|
|
205
|
+
print(SEP)
|
|
206
|
+
|
|
207
|
+
for r_au, hist in results.items():
|
|
208
|
+
if len(hist) == 0:
|
|
209
|
+
print(f"{r_au:>8.2f} -- no accretion")
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
_, M, M_iso, *M_species = hist[-1]
|
|
213
|
+
|
|
214
|
+
M_total = sum(M_species)
|
|
215
|
+
|
|
216
|
+
line = f"{r_au:>8.2f} {M/c.M_EARTH:>11.3f} {M_iso/c.M_EARTH:>11.2f}"
|
|
217
|
+
for i, m_sp in enumerate(M_species):
|
|
218
|
+
f_sp = 0.0 if M_total <= 0 else 100 * m_sp / M_total
|
|
219
|
+
line += f"{f_sp:>{col_widths[i]}.1f}"
|
|
220
|
+
|
|
221
|
+
print(line)
|
|
222
|
+
print(f"{SEP}\n")
|
|
223
|
+
|
|
224
|
+
def calculate_isolation_mass_map(self) -> np.ndarray:
|
|
225
|
+
"""Calcula el mapa de masa de aislamiento teórico 2D."""
|
|
226
|
+
h_gas = self.data.gas_Hp / self.data.r
|
|
227
|
+
M_iso = 25 * (h_gas / 0.05)**3 * self.data.M_star * c.M_EARTH
|
|
228
|
+
return np.maximum(M_iso, 0.1 * c.M_EARTH)
|
pa3py/plotting.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plotting.py - Herramientas de visualización (ej: Diagrama Hovmöller)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from matplotlib.colors import LogNorm
|
|
8
|
+
from .data import DiskData
|
|
9
|
+
from . import constants as c
|
|
10
|
+
|
|
11
|
+
def plot_hovmoller(disk: DiskData, field: str = 'dust_Sigma',
|
|
12
|
+
cmap: str = 'magma', vmin: float = None, vmax: float = None,
|
|
13
|
+
t_unit: str = 'Myr', show_snowlines: bool = True):
|
|
14
|
+
"""Genera un diagrama de Hovmöller (Radio vs Tiempo) para una propiedad."""
|
|
15
|
+
# 1. Extraer los datos 2D (Tiempo, Radio)
|
|
16
|
+
if field == 'dust_Sigma':
|
|
17
|
+
# Sumamos sobre todos los tamaños de polvo (último índice)
|
|
18
|
+
Z = np.sum(disk.dust_Sigma, axis=-1)
|
|
19
|
+
title = r"Hovmöller: $\Sigma_{dust}$ [g/cm$^2$]"
|
|
20
|
+
elif field == 'gas_Sigma':
|
|
21
|
+
Z = disk.gas_Sigma
|
|
22
|
+
title = r"Hovmöller: $\Sigma_{gas}$ [g/cm$^2$]"
|
|
23
|
+
elif field in ['eps', 'epsilon']:
|
|
24
|
+
dust = np.sum(disk.dust_Sigma, axis=-1)
|
|
25
|
+
gas = disk.gas_Sigma
|
|
26
|
+
# Le sumamos 1e-300 al gas para evitar división por cero.
|
|
27
|
+
Z = dust / (gas + 1e-300)
|
|
28
|
+
Z = np.clip(Z, 1e-10, None) # Limitar valores muy pequeños
|
|
29
|
+
title = r"Hovmöller: Dust-to-gas ratio ($\epsilon = \Sigma_d / \Sigma_g$)"
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError("Unsupported field. Options: 'dust_Sigma', 'gas_Sigma', 'epsilon'.")
|
|
32
|
+
|
|
33
|
+
# Limpiar ceros para escala log
|
|
34
|
+
Z = np.clip(Z, 1e-20, None)
|
|
35
|
+
|
|
36
|
+
t_map = {'Myr': (c.YEAR * 1e6, "Time [Myr]"), 'kyr': (c.YEAR * 1e3, "Time [kyr]")}
|
|
37
|
+
t_factor, t_label = t_map.get(t_unit, (c.YEAR, "Time [Years]"))
|
|
38
|
+
|
|
39
|
+
t_array = disk.times / t_factor
|
|
40
|
+
r_array = disk.r / c.AU
|
|
41
|
+
|
|
42
|
+
# Ejes extendidos robustos para pcolormesh
|
|
43
|
+
def _log_edges(arr):
|
|
44
|
+
arr = np.maximum(arr, 1e-10)
|
|
45
|
+
log_arr = np.log10(arr)
|
|
46
|
+
mid = (log_arr[:-1] + log_arr[1:]) / 2.0
|
|
47
|
+
return 10**np.concatenate([[log_arr[0] - (mid[0] - log_arr[0])], mid, [log_arr[-1] + (log_arr[-1] - mid[-1])]])
|
|
48
|
+
|
|
49
|
+
# Prevenir log10(0)
|
|
50
|
+
safe_t = np.maximum(t_array, 1e-6)
|
|
51
|
+
t_edges = _log_edges(safe_t)
|
|
52
|
+
r_edges = _log_edges(r_array)
|
|
53
|
+
|
|
54
|
+
# Escala de colores automática
|
|
55
|
+
logZ = np.log10(Z[Z > 1e-20])
|
|
56
|
+
v_lo = vmin if vmin is not None else 10**np.percentile(logZ, 2)
|
|
57
|
+
v_hi = vmax if vmax is not None else 10**np.percentile(logZ, 98)
|
|
58
|
+
|
|
59
|
+
# 2. Dibujar
|
|
60
|
+
fig, ax = plt.subplots(figsize=(10, 5))
|
|
61
|
+
|
|
62
|
+
# Transponemos Z porque pcolormesh recibe Y, X -> Z debe ser (Ny, Nx)
|
|
63
|
+
pcm = ax.pcolormesh(t_edges, r_edges, Z.T,
|
|
64
|
+
norm=LogNorm(vmin=v_lo, vmax=v_hi),
|
|
65
|
+
cmap=cmap, shading='flat')
|
|
66
|
+
|
|
67
|
+
cbar = fig.colorbar(pcm, ax=ax, pad=0.02)
|
|
68
|
+
cbar.set_label("Logarithmic Scale")
|
|
69
|
+
|
|
70
|
+
ax.set_xscale('log')
|
|
71
|
+
ax.set_yscale('log')
|
|
72
|
+
ax.set_xlim(safe_t[0], safe_t[-1])
|
|
73
|
+
ax.set_ylim(r_array[0], r_array[-1])
|
|
74
|
+
ax.set_xlabel(t_label)
|
|
75
|
+
ax.set_ylabel("Radius [AU]")
|
|
76
|
+
ax.set_title(title, pad=10)
|
|
77
|
+
|
|
78
|
+
if show_snowlines and disk.hdf5_snowlines:
|
|
79
|
+
colors = ['cyan', 'white', 'lightgreen']
|
|
80
|
+
for idx, (name, rsnow) in enumerate(disk.hdf5_snowlines.items()):
|
|
81
|
+
r_au = rsnow / c.AU
|
|
82
|
+
if np.any(r_au > 0):
|
|
83
|
+
ax.plot(safe_t, r_au, ls='--', color=colors[idx % len(colors)], lw=2, label=f"Snowline {name}")
|
|
84
|
+
ax.legend(loc='lower left')
|
|
85
|
+
|
|
86
|
+
fig.tight_layout()
|
|
87
|
+
return fig, ax
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plot_population(disk, results: dict, M_iso_map: np.ndarray = None, **kwargs):
|
|
92
|
+
"""
|
|
93
|
+
Grafica la población sintética de planetas (Masa Final vs Posición Inicial).
|
|
94
|
+
|
|
95
|
+
Parámetros:
|
|
96
|
+
-----------
|
|
97
|
+
disk : DiskData
|
|
98
|
+
El objeto que contiene los datos del disco.
|
|
99
|
+
results : dict
|
|
100
|
+
Diccionario con los historiales de crecimiento de cada embrión.
|
|
101
|
+
M_iso_map : np.ndarray, opcional
|
|
102
|
+
Mapa 2D de masa de aislamiento calculada para todo el disco.
|
|
103
|
+
"""
|
|
104
|
+
radii = []
|
|
105
|
+
final_masses = []
|
|
106
|
+
water_fractions = []
|
|
107
|
+
|
|
108
|
+
for r_au, hist in results.items():
|
|
109
|
+
if len(hist) == 0:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
radii.append(float(r_au))
|
|
113
|
+
final_state = hist[-1]
|
|
114
|
+
total_m = final_state[1] / c.M_EARTH
|
|
115
|
+
|
|
116
|
+
silicate_m = final_state[3]
|
|
117
|
+
water_m = final_state[4] if len(final_state) > 4 else 0.0
|
|
118
|
+
M_total = silicate_m + water_m
|
|
119
|
+
f_water = water_m / M_total if M_total > 0 else 0.0
|
|
120
|
+
|
|
121
|
+
final_masses.append(total_m)
|
|
122
|
+
water_fractions.append(f_water * 100)
|
|
123
|
+
|
|
124
|
+
radii = np.array(radii)
|
|
125
|
+
final_masses = np.array(final_masses)
|
|
126
|
+
water_fractions = np.array(water_fractions)
|
|
127
|
+
|
|
128
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
129
|
+
|
|
130
|
+
# 1. Snowline region
|
|
131
|
+
if disk.hdf5_snowlines and 'H2O' in disk.hdf5_snowlines:
|
|
132
|
+
rsnow_array = disk.hdf5_snowlines['H2O'] / c.AU
|
|
133
|
+
rsnow_valid = rsnow_array[rsnow_array > 0]
|
|
134
|
+
if len(rsnow_valid) > 0:
|
|
135
|
+
rsnow_min = np.min(rsnow_valid)
|
|
136
|
+
rsnow_max = np.max(rsnow_array)
|
|
137
|
+
ax.axvspan(rsnow_min, rsnow_max, color='cyan', alpha=0.1, label='Snowline Migration')
|
|
138
|
+
ax.axvline(rsnow_min, color='cyan', linestyle='--', alpha=0.5)
|
|
139
|
+
ax.axvline(rsnow_max, color='cyan', linestyle='--', alpha=0.5)
|
|
140
|
+
|
|
141
|
+
# 2. Isolation Mass from the full disk grid
|
|
142
|
+
if M_iso_map is not None:
|
|
143
|
+
# Usamos el límite en el último paso de tiempo (estado final del disco)
|
|
144
|
+
iso_mass_final = M_iso_map[-1, :] / c.M_EARTH
|
|
145
|
+
r_grid_au = disk.r / c.AU
|
|
146
|
+
ax.plot(r_grid_au, iso_mass_final, color='gray', linestyle='--', alpha=0.7, zorder=1, label='Isolation Mass Limit')
|
|
147
|
+
|
|
148
|
+
# 3. Planetas
|
|
149
|
+
sc = ax.scatter(radii, final_masses, c=water_fractions, cmap='Blues', edgecolor='black',
|
|
150
|
+
s=80, alpha=0.9, zorder=2, vmin=0, vmax=50)
|
|
151
|
+
|
|
152
|
+
cbar = fig.colorbar(sc, ax=ax)
|
|
153
|
+
cbar.set_label('Water Mass Fraction [%]', rotation=270, labelpad=15)
|
|
154
|
+
|
|
155
|
+
ax.set_xscale('log')
|
|
156
|
+
ax.set_yscale('log')
|
|
157
|
+
ax.set_xlabel('Initial Radius [AU]')
|
|
158
|
+
ax.set_ylabel('Final Mass [$M_\\oplus$]')
|
|
159
|
+
ax.set_title('Synthetic Population: Final Mass vs Initial Position')
|
|
160
|
+
ax.grid(True, which='both', linestyle=':', alpha=0.6)
|
|
161
|
+
ax.legend(loc='upper right')
|
|
162
|
+
|
|
163
|
+
fig.tight_layout()
|
|
164
|
+
return fig, ax
|
pa3py/snowline.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snowline.py - Cálculo analítico de la posición de la snowline en base a Oka et al. (2011) y Hartmann.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.interpolate import interp1d
|
|
8
|
+
from . import constants as c
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# INICIALIZACIÓN DE LA INTERPOLACIÓN (Ejecutada al importar)
|
|
12
|
+
# ============================================================
|
|
13
|
+
|
|
14
|
+
# Ruta robusta al CSV (empaquetado junto al módulo)
|
|
15
|
+
_current_dir = os.path.dirname(__file__)
|
|
16
|
+
_csv_path = os.path.join(_current_dir, "data_files", "oka_et_al_2011_data_corrected.csv")
|
|
17
|
+
|
|
18
|
+
# Leer datos velozmente con NumPy
|
|
19
|
+
_data = np.loadtxt(_csv_path, delimiter=';', skiprows=1)
|
|
20
|
+
_mdot_raw, _rsnow_raw = _data[:, 0], _data[:, 1]
|
|
21
|
+
|
|
22
|
+
# Asegurar orden monotónico para la interpolación log-log
|
|
23
|
+
_sort_idx = np.argsort(_mdot_raw)
|
|
24
|
+
_mdot_sorted = _mdot_raw[_sort_idx]
|
|
25
|
+
_rsnow_sorted = _rsnow_raw[_sort_idx]
|
|
26
|
+
|
|
27
|
+
_mdot_unique, _unique_indices = np.unique(_mdot_sorted, return_index=True)
|
|
28
|
+
_rsnow_unique = _rsnow_sorted[_unique_indices]
|
|
29
|
+
|
|
30
|
+
_log_mdot = np.log10(_mdot_unique)
|
|
31
|
+
_log_rsnow = np.log10(_rsnow_unique)
|
|
32
|
+
|
|
33
|
+
# Interpolador principal
|
|
34
|
+
_interp_log_rsnow = interp1d(
|
|
35
|
+
_log_mdot,
|
|
36
|
+
_log_rsnow,
|
|
37
|
+
kind='linear',
|
|
38
|
+
bounds_error=False,
|
|
39
|
+
fill_value="extrapolate"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ============================================================
|
|
43
|
+
# FUNCIONES FÍSICAS PÚBLICAS
|
|
44
|
+
# ============================================================
|
|
45
|
+
|
|
46
|
+
def get_rsnow_from_mdot_au(mdot_val):
|
|
47
|
+
"""Retorna la posición de snowline en AU dada una tasa de acreción."""
|
|
48
|
+
log_m = np.log10(mdot_val)
|
|
49
|
+
log_r = _interp_log_rsnow(log_m)
|
|
50
|
+
return 10**log_r
|
|
51
|
+
|
|
52
|
+
def mdot_time(t_myr, eta=1.5):
|
|
53
|
+
"""Calcula la tasa de acreción de gas en el tiempo (Hartmann)."""
|
|
54
|
+
mdot_1myr = 1e-8
|
|
55
|
+
# Proteger contra tiempo 0 para evitar singularidades matemáticas
|
|
56
|
+
t_myr = np.maximum(t_myr, 1e-6)
|
|
57
|
+
return mdot_1myr * (t_myr)**(-eta)
|
|
58
|
+
|
|
59
|
+
def r_snow_time_cgs(t_sec, eta=1.5, r_min_au=0.5):
|
|
60
|
+
"""Posición de la snowline en CGS dado el tiempo en segundos."""
|
|
61
|
+
t_myr = t_sec / (1e6 * c.YEAR)
|
|
62
|
+
m_eff = min(mdot_time(t_myr, eta), 1e-7) # cap para r_snow ≤ 2.73 AU
|
|
63
|
+
r_au = np.clip(get_rsnow_from_mdot_au(m_eff), r_min_au, 2.73)
|
|
64
|
+
return float(r_au * c.AU)
|
|
65
|
+
|
|
66
|
+
def generate_rsnow_array(times_sec: np.ndarray, eta: float = 1.5, r_min_au: float = 0.5) -> np.ndarray:
|
|
67
|
+
"""Arreglo de posiciones de la snowline en CGS para inyectar en CompositionModels."""
|
|
68
|
+
return np.array([r_snow_time_cgs(t, eta=eta, r_min_au=r_min_au) for t in times_sec])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pa3py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Post-processing tool for pebble accretion onto planetary embryos from TripodPy hydrodynamic simulations.
|
|
5
|
+
Author-email: Maximiliano Valderrama <mvalderrav@uc.cl>
|
|
6
|
+
License: MIT License
|
|
7
|
+
Project-URL: Homepage, https://github.com/mmmaxii/PA3Py
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/mmmaxii/PA3Py/issues
|
|
9
|
+
Project-URL: Documentation, https://pa3py.readthedocs.io
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numpy>=1.20
|
|
19
|
+
Requires-Dist: scipy>=1.7
|
|
20
|
+
Requires-Dist: h5py>=3.0
|
|
21
|
+
Requires-Dist: matplotlib>=3.4
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: jupyter>=1.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# PA3Py — Pebble Accretion Post-Processing for TripodPy
|
|
28
|
+
|
|
29
|
+
[](https://opensource.org/licenses/MIT) [](https://github.com/mmmaxii/PA3Py/actions) [](https://pa3py.readthedocs.io/en/latest/?badge=latest)
|
|
30
|
+
|
|
31
|
+
**PA3Py** is a Python post-processing package for computing pebble accretion growth of planetary embryos from [TripodPy](https://github.com/tripod-code/tripodpy) hydrodynamic disk simulations. It is **not** a standalone disk evolution code — it reads TripodPy HDF5 output snapshots and computes embryo growth on top of them.
|
|
32
|
+
|
|
33
|
+
The physics follows Ormel (2017) and Drążkowska et al. (2023), including headwind/shear regime switching, 2D–3D turbulence transition, Safronov ballistic onset, and a dynamic isolation mass cap.
|
|
34
|
+
|
|
35
|
+
Please read the [documentation](https://pa3py.readthedocs.io) for a full description.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Clone the repository and install via pip:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/mmmaxii/PA3Py.git
|
|
43
|
+
cd PA3Py
|
|
44
|
+
pip install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For an editable installation (recommended during development):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python ≥ 3.9
|
|
56
|
+
- [TripodPy](https://github.com/tripod-code/tripodpy) simulation output (HDF5 snapshots)
|
|
57
|
+
- `numpy`, `scipy`, `h5py`, `matplotlib`
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pa3py import PA3Py
|
|
63
|
+
|
|
64
|
+
sim = PA3Py("path/to/tripodpy/output/")
|
|
65
|
+
|
|
66
|
+
# Run pebble accretion for embryos at 2, 5, 10, 20 AU
|
|
67
|
+
results = sim.run_growth([2.0, 5.0, 10.0, 20.0])
|
|
68
|
+
|
|
69
|
+
# Hovmöller diagram (dust-to-gas ratio vs time)
|
|
70
|
+
sim.plot_hovmoller(field='epsilon')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For custom chemistry (multi-snowline, 4-species):
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pa3py import PA3Py, FunctionComposition
|
|
77
|
+
from pa3py import constants as c
|
|
78
|
+
|
|
79
|
+
def my_chemistry(r_cm, t_sec):
|
|
80
|
+
if r_cm < 3.0 * c.AU:
|
|
81
|
+
return {'silicates': 1.0}
|
|
82
|
+
elif r_cm < 5.0 * c.AU:
|
|
83
|
+
return {'silicates': 0.5, 'H2O': 0.5}
|
|
84
|
+
else:
|
|
85
|
+
return {'silicates': 0.3, 'H2O': 0.3, 'CO2': 0.4}
|
|
86
|
+
|
|
87
|
+
sim = PA3Py("path/to/output/", comp_model=FunctionComposition(my_chemistry))
|
|
88
|
+
results = sim.run_growth([5.0, 10.0, 20.0])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Physics & References
|
|
92
|
+
|
|
93
|
+
- **Ormel (2017)**: *The Emerging Paradigm of Pebble Accretion*
|
|
94
|
+
- **Drążkowska et al. (2023)**: *Planet Formation Theory in the Era of ALMA and Kepler*
|
|
95
|
+
|
|
96
|
+
## Acknowledgements
|
|
97
|
+
|
|
98
|
+
PA3Py was developed at the Pontificia Universidad Católica de Chile.
|
|
99
|
+
Contact: [mvalderrav@uc.cl](mailto:mvalderrav@uc.cl)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pa3py/__init__.py,sha256=iJNsOu2OZGIaezgJj1-EKmWEl8S3gdIsZjjAkLJ0eZ8,2000
|
|
2
|
+
pa3py/composition.py,sha256=W2fnykhQ9w2IpAk1NUa9sYGyrhnWAidZv5m1xLKJzbs,4775
|
|
3
|
+
pa3py/constants.py,sha256=Hw5ngIZ_213OX-9suhGVreZ4uYdmDtkfdgdpe6EGvQ4,342
|
|
4
|
+
pa3py/core.py,sha256=dXoUt4GnctIms4Xue0SNWor-UL1Z-Qk40T2M2X5hdJo,4387
|
|
5
|
+
pa3py/data.py,sha256=f5m3XNsxDt12fmDdLMWveDWtQfAdeYemoh_WO5MFtWk,4067
|
|
6
|
+
pa3py/pebble_accretion.py,sha256=slKaD3Ea8g9eKwOYXTPscNTbwAAApytcvG9ZYDN9650,10605
|
|
7
|
+
pa3py/plotting.py,sha256=eTdGX4AjzN5P_07v6PFNO-71h1WykT4jHiOYUeuUgKk,6301
|
|
8
|
+
pa3py/snowline.py,sha256=Zu3oTM0OjjJf_0CRyNRXlYDyJbk68tX8J-BoppXNcFs,2505
|
|
9
|
+
pa3py-1.0.0.dist-info/licenses/LICENSE,sha256=QPBZoUVz-h3pH3QzXiYxW7KKU99taI3Kkv_elegznnU,1100
|
|
10
|
+
pa3py-1.0.0.dist-info/METADATA,sha256=60sRiNCZ_CBqNu-XaxvzxRoJXM5qVhBsCPUZz5HlIU0,3665
|
|
11
|
+
pa3py-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
pa3py-1.0.0.dist-info/top_level.txt,sha256=BzQcdvwZbyD7sGzqakRoLo9-0Jq7zm2sn17ORXanzoI,6
|
|
13
|
+
pa3py-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maximiliano Valderrama
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pa3py
|