## User Guide
This comprehensive guide provides detailed tutorials, advanced usage examples, and best practices for using `PUFFIN` to model externally irradiated protoplanetary disks with photoevaporative winds.
***
### Tutorial 1: Basic 1D Model
#### Overview
The 1D model computes the radial density structure along the midplane. This is the simplest and fastest option, ideal for quick parameter studies.
#### Basic Usage
```python
import puffin_disk
import numpy as np
import matplotlib.pyplot as plt
# Define physical parameters
m_star = 1.0 # Stellar mass (M_sun)
r_d = 100.0 # Disk outer radius (AU)
sigma_1au = 100.0 # Surface density at 1 AU (g cm^-2)
F_FUV = 1000.0 # External FUV field (G_0)
# Create and compute model
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV)
r_array, rho_array = model.compute()
# Plot the result
plt.figure(figsize=(8, 6))
plt.loglog(r_array, rho_array, linewidth=2, color='#2E86AB')
plt.xlabel('Radius (AU)', fontsize=14)
plt.ylabel(r'Midplane Density (g cm$^{-3}$)', fontsize=14)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('density_1d.png', dpi=300)
plt.show()
```
#### Understanding the Output
The `compute()` method returns two arrays:
- **`r_array`**: Radial grid in AU (default: 200 logarithmically-spaced points)
- **`rho_array`**: Gas density at the midplane in g cm⁻³
The model automatically combines three components:
1. **Disk interior**: Power-law surface density with exponential taper
2. **Photoevaporative wind**: Spherically diverging outflow
3. **Transition region**: Smooth connection using 'plateau' prescription (see Keyte & Haworth 2026)
You can also access individual components:
```python
rho_disk = model.rho_disk # Disk component only
rho_wind = model.rho_wind # Wind component only
rho_plateau = model.rho_plateau # Transition region
```
#### Key Features
**Automatic mass-loss rate interpolation**: If you don't specify `m_dot`, `PUFFIN` automatically interpolates from the `FRIED` grid based on your input parameters:
```python
# Mass-loss rate interpolated automatically
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV)
r_array, rho_array = model.compute()
# Check what was used
print(f"Interpolated mass-loss rate: {model.m_dot:.2e} M_sun/yr")
```
**Console output**: By default, `PUFFIN` prints a formatted table showing all model parameters:
```
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ INITIALIZATION ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
> Model initialized
┌────────────────────┬───────────────┬──────────┐
│ Parameter │ Value │ Unit │
├────────────────────┼───────────────┼──────────┤
│ Stellar mass │ 1.00 │ M_sun │
│ Disk radius │ 100.0 │ AU │
│ Sigma (1AU) │ 100.0 │ g cm⁻² │
│ FUV field │ 1.00 × 10³ │ G0 │
│ N grid cells │ 200 │ │
│ Grid size │ 800.0 │ AU │
│ Mass-loss rate │ 1.23 × 10⁻⁸ │ M_sun/yr │
│ Gamma │ 2.45 │ │
│ p │ 0.20 │ │
│ q │ 0.40 │ │
└────────────────────┴───────────────┴──────────┘
```
To disable console output:
```python
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, verbose=False)
```
***
### Tutorial 2: Basic 2D Model
#### Overview
The 2D model expands the framework by incorporating the disk’s vertical structure under hydrostatic equilibrium, the photoevaporative wind, and a transition region that smoothly links the disk to the wind.
#### Basic Usage
```python
import puffin_disk
import numpy as np
import matplotlib.pyplot as plt
# Define physical parameters (same as 1D)
m_star = 0.5 # Stellar mass (M_sun)
r_d = 100.0 # Disk outer radius (AU)
sigma_1au = 1000.0 # Surface density at 1 AU (g cm^-2)
F_FUV = 3000.0 # External FUV field (G_0)
# Create and compute 2D model
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV)
r_array, z_array, rho_array = model.compute()
# Create contour plot
fig, ax = plt.subplots(figsize=(10, 6))
levels = np.arange(-20, -11, 0.2)
contour = ax.contourf(r_array, z_array, np.log10(rho_array),
levels=levels, cmap='Spectral_r', extend='both')
ax.set_xlabel('Radius (AU)', fontsize=14)
ax.set_ylabel('Height (AU)', fontsize=14)
cbar = plt.colorbar(contour, ax=ax, label=r'log$_{10}$ Density (g cm$^{-3}$)')
ax.set_aspect('equal')
plt.tight_layout()
plt.savefig('density_2d.png', dpi=300)
plt.show()
```
#### Understanding the Output
The `compute()` method returns three arrays:
- **`r_array`**: Radial grid in AU (default: 1000 points)
- **`z_array`**: Vertical grid in AU (default: 1000 points)
- **`rho_array`**: 2D gas density array in g cm⁻³ with shape `(nz, nr)`
**Important**: The density array uses row ordering where the first dimension is vertical (z) and the second is radial (r). When plotting, use `contourf(r_array, z_array, rho_array)`.
Access model components:
```python
rho_disk = model.rho_disk # Disk component with hydrostatic structure
rho_wind = model.rho_wind # Combined wind (spherical + transition)
```
The typical runtime for a 2D model with `n_points=1000` is ~2-3 minutes on a standard laptop.
***
### Tutorial 3: Customizing Model Parameters
#### Grid Resolution
**1D models**: Default is 200 points. Higher resolution is recommended for capturing sharper density gradients:
```python
# Standard resolution
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, n_points=200)
# High resolution for detailed structure
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, n_points=1000)
```
**2D models**: Default is 1000×1000. Resolution ≥1000 is required for accurate hydrostatic equilibrium:
```python
# Standard resolution
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, n_points=1000)
# High resolution for detailed structure
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, n_points=2000)
```
If you need a lower-resolution model (e.g., for use as input to thermochemical calculations), first compute the `PUFFIN` model with at least 1000 grid points, and then regrid it to the desired resolution.
#### Grid Size
The computational domain extends from 0 to `gridsize` (in AU). Default is `min(8 × r_d, 800)` AU. Generally, `8 × r_d` is required to capture the full extent of dense photoevaporative winds.
```python
# Modify the gidsize (in AU)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, gridsize=1500)
```
#### Mass-Loss Rate
Override automatic interpolation with your own value:
```python
# Specify mass-loss rate directly (M_sun/yr)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, m_dot=1e-8)
# For 2D models, note that PUFFIN applies 2× scaling internally
# to match systematic differences in 2D hydrodynamical simulations
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, m_dot=1e-8)
```
#### Taper Parameter (γ)
The exponential tapering parameter `gamma` controls how steeply the disk truncates. `PUFFIN` uses an empirical scaling by default, but you can override it:
```python
# Use default empirical prescription
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV)
# Sharper cutoff (higher gamma)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, gamma=4.0)
# Gentler cutoff (lower gamma)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, gamma=2.0)
```
#### Transition Region Parameters
Fine-tune the disk-wind transition:
```python
# Standard transition (default)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, p=0.2, q=0.4)
# Slower transition (more extended plateau)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, p=0.1, q=0.2)
# Faster transition (sharper disk-wind interface)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV, p=0.3, q=0.6)
```
#### Temperature Gradient Steepness (2D only)
Control the vertical temperature transition sharpness with parameter `k`:
```python
# Standard gradient (default)
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, k=1.75)
# Sharper temperature gradient
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, k=3.0)
# Gentler gradient
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, k=1.0)
```
#### Hydrostatic Equilibrium Iterations (2D only)
Increase iterations if the model fails to converge:
```python
# Standard iteration count (default: 20)
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, N_ITER=20)
# More iterations for better convergence
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, N_ITER=50)
```
Most models converge within the default 20 iterations.
***
### Tutorial 4: Parameter Space Exploration
Efficiently explore how disk properties vary across parameter space:
```python
import puffin_disk
import numpy as np
import matplotlib.pyplot as plt
# Define parameter grid
masses = [0.3, 0.7, 2.0] # M_sun
FUV_fields = [1e2, 1e3, 1e4] # G_0
# Fixed parameters
r_d = 100.0
sigma_1au = 100.0
# Storage for results
results = {}
# Loop over parameter combinations
for m_star in masses:
for F_FUV in FUV_fields:
key = f"M{m_star}_F{F_FUV:.0e}"
# Compute model (use verbose=False to reduce output)
model = puffin_disk.DiskModel1D(m_star, r_d, sigma_1au, F_FUV,
verbose=False)
r, rho = model.compute()
results[key] = {
'radius': r,
'density': rho,
'm_star': m_star,
'F_FUV': F_FUV,
'm_dot': model.m_dot
}
# Plot family of density profiles
fig, ax = plt.subplots(figsize=(10, 6))
for key, data in results.items():
label = f"M={data['m_star']} M☉, F={data['F_FUV']:.0e} G₀"
ax.loglog(data['radius'], data['density'], label=label, linewidth=2)
ax.set_xlabel('Radius (AU)', fontsize=14)
ax.set_ylabel(r'Density (g cm$^{-3}$)', fontsize=14)
ax.legend(fontsize=10)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('parameter_scan.png', dpi=300)
plt.show()
```
***
### Tutorial 5: Integration with Chemical Models
#### Using `PUFFIN` density structure as input to `DALI` chemical model
`DALI` (Bruderer et al. 2012, 2013) requires a specific `setup_grid.dat` file format that includes not only the gas density structure, but also grid cell boundaries, gas-to-dust ratios, and dust settling prescriptions.
Because `PUFFIN` computes only the gas density structure, converting `PUFFIN` outputs to `DALI` format requires you to specify additional disk properties:
- Gas-to-dust ratio in the shielded disk interior
- Gas-to-dust ratio in the dust-depleted wind
- Dust settling parameters (scale height and grain size distribution)
- Physical criterion for the disk-wind boundary (e.g., optical depth threshold, density threshold)
This tutorial demonstrates an example workflow based on the implementation in Keyte & Haworth (2025), where the transition from disk to wind is defined using an arbitrary density threshold n=10⁸ cm⁻³. Note that these properties are science choices that depend on your specific research questions—there are no universal "correct" values.
##### Step 1: Save PUFFIN Output
First, run `PUFFIN` and save the density structure:
```python
import puffin_disk
import numpy as np
from scipy.interpolate import RegularGridInterpolator
# Generate 2D model at high resolution
m_star = 1.0
r_d = 100.0
sigma_1au = 1000.0
F_FUV = 1000.0
# Compute at high resolution for accuracy
model = puffin_disk.DiskModel2D(m_star, r_d, sigma_1au, F_FUV, n_points=1000)
r_array_hr, z_array_hr, rho_array_hr = model.compute()
# Convert density to number density
mu = 1.4 # Mean molecular weight
m_p = 1.67262192e-24 # Proton mass in g
n_gas_hr = rho_array_hr / (mu * m_p)
# ========================================================================
# REGRID TO LOWER RESOLUTION FOR DALI
# ========================================================================
# Define lower resolution grid for DALI (200x200)
n_r_dali = 200
n_z_dali = 200
# Create grid
r_array = np.logspace(np.log10(r_array_hr[0]), np.log10(r_array_hr[-1]), n_r_dali)
z_min = 1e-2 # Small offset to avoid log(0)
z_array_log = np.logspace(np.log10(z_min), np.log10(z_array_hr[-1]), n_z_dali - 1)
z_array = np.concatenate([[0.0], z_array_log]) # z needs to start at 0 in DALI
# Create interpolator for high-resolution density
interpolator = RegularGridInterpolator(
(r_array_hr, z_array_hr),
n_gas_hr.T, # Transpose to (nr, nz) for interpolator
method='linear',
bounds_error=False,
fill_value=1e-30
)
# Interpolate to DALI grid
n_gas = np.zeros((n_z_dali, n_r_dali))
for i in range(n_r_dali):
for j in range(n_z_dali):
n_gas[j, i] = interpolator([r_array[i], z_array[j]])
print(f"Regridded from {len(r_array_hr)}x{len(z_array_hr)} to {n_r_dali}x{n_z_dali}")
# ========================================================================
# Define disk-wind boundary using density threshold
# Cells above this threshold are "disk", below are "wind"
n_threshold = 1e8 # cm^-3 (typical disk-wind boundary)
# Flatten arrays for saving
n_r, n_z = len(r_array), len(z_array)
r_flat = []
z_flat = []
n_flat = []
for i in range(n_r):
for j in range(n_z):
r_flat.append(r_array[i])
z_flat.append(z_array[j])
n_flat.append(n_gas[j, i])
# Save to file
output_file = 'puffin_outputs.dat'
np.savetxt(output_file,
np.column_stack([r_flat, z_flat, n_flat]),
header=f'r(AU) z(AU) n_gas(cm^-3) | Grid: {n_r}x{n_z} | n_threshold={n_threshold:.1e} cm^-3',
fmt='%.6e')
print(f"PUFFIN output saved to {output_file}")
print(f"Grid dimensions: {n_r} x {n_z}")
print(f"Disk-wind boundary set at n = {n_threshold:.1e} cm^-3")
```
##### Step 2: Create DALI Setup File
Now create the DALI `setup_grid.dat` file. This includes additional physics choices about dust properties:
```python
import numpy as np
# Load PUFFIN output
data = np.loadtxt('puffin_outputs.dat', unpack=True)
# Grid dimensions (200x200 after regridding)
n_r = 200 # Number of radial grid points
n_z = 200 # Number of vertical grid points
# Reshape to 2D grids
rr = np.reshape(data[0], (n_r, n_z))
zz = np.reshape(data[1], (n_r, n_z))
ngas = np.reshape(data[2], (n_r, n_z))
# ========================================================================
# DUST PARAMETERS - These are user choices based on your science goals
# ========================================================================
gd_disk = 100 # Gas-to-dust ratio in disk (standard ISM value)
gd_wind = 1/3e-4 # Gas-to-dust in wind (dust-depleted, only small grains)
chi = 0.2 # Scale height parameter for settling
f = 0.9 # Fraction of large grains at midplane
n_threshold = 1e8 # Density threshold for disk-wind boundary (cm^-3)
# ========================================================================
# Create DALI setup_grid.dat file
outfile = 'setup_grid.dat'
with open(outfile, "w") as f_new:
# Write header
f_new.write("# number of cells in r-direction \n")
f_new.write("%i \n" % (n_r - 1))
f_new.write("# number of cells in z-direction \n")
f_new.write("%i \n" % (n_z - 1))
f_new.write("# i_r i_z ra rb za zb n_gas gas-to-dust f_ls region \n")
# Loop over grid cells
for i in range(n_r - 1):
# Since PUFFIN directly provides the gas density structure, we first compute
# h(r) from the gas density at each radius, then apply the 'standard' DALI
# dust prescriptions (Bruderer 2013, equations 2 and 3)
# Calculate scale height at this radius
# (defined where density drops to 1/sqrt(e) of midplane value)
max_dens = np.max(ngas[i, :])
try:
scaleheight_idx = np.where(ngas[i, :] < max_dens * np.exp(-0.5))[0][0]
scaleheight_au = zz[i, scaleheight_idx]
except:
scaleheight_idx = 0
scaleheight_au = zz[i, 0]
for j in range(n_z - 1):
# Cell boundaries
ra = rr[i][j]
rb = rr[i+1][j]
if j == 0:
za = 0.0
zb = zz[i][j+1]
else:
za = zz[i][j]
zb = zz[i][j+1]
# Cell midpoints
r_mid = 0.5 * (ra + rb)
z_mid = max(0.5 * (za + zb), 1e-10) # Avoid division by zero
# Current cell density
n_gas = ngas[i][j]
# ===============================================
# DISK vs WIND: Use density threshold
# ===============================================
is_disk = (n_gas > n_threshold)
# ===============================================
# LARGE GRAIN FRACTION (f_ls) - Dust Settling
# ===============================================
# This part is a formulation of Eq. 2 and 3 from Bruderer 2013, using the
# scale height determined above,
# Scale height angle and polar angle
h = np.arctan(scaleheight_au / r_mid)
h = np.maximum(h, 1e-12)
theta = np.arctan(r_mid / z_mid) # Polar angle from z-axis
if is_disk:
# Inside dense disk: calculate settling
prefactor = f / ((1 - f) * chi)
term1 = -0.5 * (((np.pi/2) - theta) / (chi * h))**2
term2 = 0.5 * (((np.pi/2) - theta) / h)**2
expo = np.exp(term1 + term2)
# f_ls = fraction of large grains
X_ratio = prefactor * expo
f_ls = X_ratio / (1.0 + X_ratio)
f_ls = np.maximum(1e-10, f_ls)
else:
# Wind region: no large grains
f_ls = 1e-10
# ===============================================
# GAS-TO-DUST RATIO
# ===============================================
if is_disk:
gtd = gd_disk # Dense disk
else:
gtd = gd_wind # Tenuous wind (dust-depleted)
# Region identifier (typically 1 for entire disk)
region = 1
# Write row
f_new.write("%6i %6i %15.10e %15.10e %15.10e %15.10e %15.10e %15.10e %15.10e %6i \n"
% (i+1, j+1, ra, rb, za, zb, n_gas, gtd, f_ls, region))
print(f"DALI setup file created: {outfile}")
print(f"Grid dimensions: {n_r-1} x {n_z-1} cells")
print(f"Disk-wind boundary: n > {n_threshold:.1e} cm^-3")
print("Ready to run DALI with this grid")
```
##### Understanding the DALI Format
The `setup_grid.dat` file has the following structure:
**Header:**
- Number of radial cells
- Number of vertical cells
- Column descriptions
**Data columns (for each cell):**
1. **i_r**: Radial cell index
2. **i_z**: Vertical cell index
3. **ra**: Inner radial boundary (AU)
4. **rb**: Outer radial boundary (AU)
5. **za**: Lower vertical boundary (AU)
6. **zb**: Upper vertical boundary (AU)
7. **n_gas**: Gas number density (cm⁻³)
8. **gas-to-dust**: Gas-to-dust mass ratio
9. **f_ls**: Large grain fraction (0-1)
10. **region**: Region identifier (typically 1)
##### Key Physics Choices
When creating `DALI` input, you must specify dust parameters based on your science goals:
**Gas-to-Dust Ratio:**
- **`gd_disk`** (e.g., 100): Standard ISM value for shielded disk interior
- **`gd_wind`** (e.g., 3000 or higher): Dust-depleted wind where grains have settled/been lost (eg. Facchini et al. 2016)
- Disk/wind boundary based on eg. density threshold (see Keyte & Haworth 2025) or optical depth τ = 1 threshold (see Keyte & Haworth 2026)
**Dust Settling Parameters:**
- **`f`** (e.g., 0.9): Fraction of large grains at the midplane
- **`chi`** (e.g., 0.2): Ratio of dust scale height to gas scale height
- These control vertical stratification of grain sizes
The **f_ls** (large grain fraction) calculation implements a settling prescription where:
- Large grains settle toward the midplane
- Small grains remain well-mixed
- The vertical distribution depends on the local scale height angle
**Important Notes:**
1. **Temperature fields**: As always, do NOT use `PUFFIN` temperatures. `DALI` will compute self-consistent temperatures through radiative transfer.
2. **Dust choices are yours**: The gas-to-dust ratios and settling parameters shown here are examples. Adjust based on your specific disk model and science questions.
##### Verification
After creating the setup file, verify it looks correct:
```python
# Quick visual check
import matplotlib.pyplot as plt
# Load the created setup file
setup_data = np.loadtxt('setup_grid.dat', skiprows=4, unpack=True)
# Reshape to 2D (note: n_r-1 and n_z-1 cells)
n_cells_r = n_r - 1
n_cells_z = n_z - 1
setup_r = np.reshape(setup_data[2], (n_cells_r, n_cells_z)) # rb values
setup_z = np.reshape(setup_data[4], (n_cells_z, n_cells_z)) # zb values
setup_ngas = np.reshape(setup_data[6], (n_cells_r, n_cells_z))
setup_fls = np.reshape(setup_data[8], (n_cells_r, n_cells_z))
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
# Plot density
levels = np.arange(2, 14, 0.2)
im1 = axes[0].contourf(setup_r, setup_z, np.log10(setup_ngas),
levels=levels, cmap='Spectral_r', extend='both')
axes[0].set_xlabel('Radius (AU)')
axes[0].set_ylabel('Height (AU)')
axes[0].set_title('Gas Number Density')
plt.colorbar(im1, ax=axes[0], label='log₁₀ n (cm⁻³)')
# Plot settling
im2 = axes[1].contourf(setup_r, setup_z, setup_fls,
levels=np.arange(0, 1, 0.1), cmap='Spectral_r', extend='both')
axes[1].set_xlabel('Radius (AU)')
axes[1].set_ylabel('Height (AU)')
axes[1].set_title('Large Grain Fraction')
plt.colorbar(im2, ax=axes[1], label='f_ls')
plt.tight_layout()
plt.savefig('dali_setup_verification.png', dpi=200)
plt.show()
```
***
### Best Practices
#### Temperature and Radition Fields: Critical Warning
**⚠️ NEVER use PUFFIN temperature/radiation fields directly for chemistry or radiative transfer.**
The temperatures computed by `PUFFIN` are used *only* to establish the density structure through hydrostatic equilibrium. They are not self-consistent with the radiation field and should not be trusted for any downstream analysis.
**Always:**
1. Use `PUFFIN` to generate density structures only
2. Input these densities into proper radiative transfer codes (`DALI`, `RADMC-3D`, `ProDiMo`)
3. Use the self-consistent temperatures and radiation fields from radiative transfer for chemistry
***
### Troubleshooting
#### Common Issues
**Issue**: "Disk mass < 1e-5 M_sun" abort message
**Cause**: Surface density too low or disk radius too small
**Solution**: Increase `sigma_1au` or `r_d`
---
**Issue**: 2D model produces unusual structure
**Cause**: Grid resolution too low or parameters produce extreme conditions
**Solutions**:
- Increase `n_points` to >>1000
- Increase `N_ITER` for better convergence
- Check parameters are within validated range
- Vary `k` parameter for gentler/steeper temperature gradients
- Manually specify `gamma`, `p`, and `q` to adjust disk-wind transition
---
**Issue**: Wind densities seem too high/low
**Cause**: Mass-loss rate interpolation or manual specification
**Solutions**:
- Check interpolated m_dot value: `print(model.m_dot)`
- Manually specify m_dot if needed
- For 2D, remember the 2× internal scaling factor
- Verify you're within `FRIED` grid coverage
---
**Issue**: Unexpected density jumps or discontinuities
**Cause**: Insufficient grid resolution or numerical artifacts
**Solutions**:
- Increase `n_points`
- Adjust `gamma` parameter for smoother taper
- Modify `p` and `q` for gentler transition
---
**Issue**: Results don't match hydrodynamical simulations exactly
**Expected**: `PUFFIN` is a parametric approximation with typical factor-of-a-few accuracy
**When to worry**: Deviations >5× suggest:
- Parameters outside validated range
- Unusual physics not captured by parametric model
- Need for full hydrodynamical simulation
***
### Further Resources
#### Code Repository
- GitHub: [https://github.com/lukekeyte/PUFFIN](https://github.com/lukekeyte/PUFFIN)
- Report bugs, request features, or contribute improvements
#### Documentation
- Full documentation: [https://puffin.readthedocs.io](https://puffin.readthedocs.io)
- Physical model details: See "Physical Model" section
- API reference: See source code docstrings
#### Key References
**PUFFIN framework:**
- Keyte & Haworth (2026), arXiv:2602.02011
**FRIED hydrodynamical grid:**
- Haworth et al. (2018), MNRAS 481, 452
- Haworth et al. (2023), MNRAS 526, 4315
#### Getting Help
1. **Check the documentation**: Most questions answered in Physical Model and FAQ sections
2. **Email support**: l.keyte@qmul.ac.uk for technical questions
3. **GitHub Issues**: For bug reports and feature requests