Numerical Methods

Table of Contents

Overview

High-altitude balloon trajectory simulation presents unique numerical challenges. The system evolves across multiple time scales: rapid thermodynamic responses occur in seconds, while full flights span hours. Altitude ranges from sea level to 40+ km introduce dramatic changes in atmospheric properties. This section details the advanced numerical methods that ensure accurate, stable, and efficient simulation.

Numerical Challenges: A balloon ascending at 5 m/s experiences pressure changes of ~1% every 100m near sea level. Temperature can swing 100°C between day/night at altitude. The numerical integrator must handle these rapid changes while maintaining accuracy over 3+ hour flights spanning 200+ km horizontally.

Dormand-Prince 8(7) Method

The simulator employs the Dormand-Prince 8(7) method, a member of the Runge-Kutta family specifically designed for solving non-stiff ordinary differential equations with high accuracy. This 8th-order method with 7th-order error estimation provides exceptional accuracy for smooth balloon dynamics.

Mathematical Foundation

For the system of ODEs:

$$\frac{d\mathbf{y}}{dt} = \mathbf{f}(t, \mathbf{y})$$ where $\mathbf{y} = [x, y, z, v_x, v_y, v_z, T_{film}, T_{gas}, m_{gas}]^T$

The Dormand-Prince method computes 13 stages:

$$\begin{align} k_1 &= f(t_n, y_n) \\ k_2 &= f(t_n + c_2h, y_n + ha_{21}k_1) \\ k_3 &= f(t_n + c_3h, y_n + h(a_{31}k_1 + a_{32}k_2)) \\ &\vdots \\ k_{13} &= f(t_n + c_{13}h, y_n + h\sum_{j=1}^{12}a_{13,j}k_j) \end{align}$$

The 8th-order solution and 7th-order error estimate are:

$$y_{n+1} = y_n + h\sum_{i=1}^{13} b_i k_i \quad \text{(8th order)}$$ $$\hat{y}_{n+1} = y_n + h\sum_{i=1}^{13} \hat{b}_i k_i \quad \text{(7th order)}$$ $$\text{error} = |y_{n+1} - \hat{y}_{n+1}| = h\sum_{i=1}^{13}(b_i - \hat{b}_i)k_i$$

Butcher Tableau

The method is defined by its Butcher tableau. Key coefficients include:

Stage $c_i$ $b_i$ (8th order) $\hat{b}_i$ (7th order) $b_i - \hat{b}_i$ (error)
1 0 5.42937890774597e-2 5.42937890774597e-2 0
6 1/2 0 -4.45031289275240 4.45031289275240
7 1/3 0 1.89151789931450 -1.89151789931450
13 1 8.42619844383543e-2 0 8.42619844383543e-2
Why DP8(7)? The 8th-order accuracy means local truncation error scales as $O(h^9)$. For typical timesteps of 1-10 seconds, this provides accuracy to ~10^-12, essential for multi-hour simulations. The embedded 7th-order solution enables reliable error estimation without additional function evaluations.

Adaptive Timestep Control

Efficient simulation requires dynamic timestep adjustment based on solution behavior. The simulator implements sophisticated PI (Proportional-Integral) control for smooth timestep adaptation.

Error-Based Step Size Control

Given error estimate $\text{err}$ and tolerance $\text{tol}$, the optimal timestep is:

$$h_{new} = h \cdot \left(\frac{\text{tol}}{\text{err}}\right)^{1/(p+1)}$$ where $p = 8$ is the method order

The PI controller smooths timestep changes:

$$h_{new} = h \cdot \left(\frac{\text{tol}}{\text{err}}\right)^{\alpha} \cdot \left(\frac{\text{err}_{old}}{\text{tol}}\right)^{\beta}$$ where $\alpha = 0.7/p$, $\beta = 0.4/p$ for DP8(7)

Multi-Component Error Scaling

Different state variables have different scales and tolerances:

def compute_error_scale(state, atol, rtol):
    """Compute error scaling for each component"""
    scales = {}

    # Position: meters
    scales['position'] = atol['position'] + rtol * np.abs(state.position)

    # Velocity: m/s
    scales['velocity'] = atol['velocity'] + rtol * np.abs(state.velocity)

    # Temperature: Kelvin
    scales['temperature'] = atol['temperature'] + rtol * np.abs(state.temperature)

    # Mass: kg (use relative tolerance only for small masses)
    scales['mass'] = atol['mass'] + rtol * np.abs(state.mass)

    return scales

def compute_weighted_error(error_vec, scales):
    """Compute RMS norm of scaled error"""
    scaled_errors = []

    for component, error in error_vec.items():
        scaled_error = error / scales[component]
        scaled_errors.extend(scaled_error.flatten())

    return np.sqrt(np.mean(np.square(scaled_errors)))

Timestep Constraints

Physical constraints impose additional timestep limits:

1. Vertical Gradient Constraint

Limit altitude change to resolve atmospheric layers:

$$h_{max,alt} = \frac{\Delta z_{max}}{|v_z|} \approx \frac{100\text{ m}}{5\text{ m/s}} = 20\text{ s}$$

2. Thermal Time Constant

Resolve temperature dynamics:

$$h_{max,thermal} = 0.1 \times \tau_{thermal} \approx 0.1 \times 300\text{ s} = 30\text{ s}$$

3. Mach Number Change

For transonic conditions:

$$h_{max,mach} = \frac{0.01}{|dM/dt|}$$

Multi-Scale Integration

Balloon dynamics span multiple time scales, requiring specialized handling:

Time Scale Separation

Process Time Scale Typical Period Integration Challenge
Acoustic oscillations $L/c$ 0.01 s Filtered by incompressible assumption
Turbulent eddies $L/v$ 0.1-1 s Modeled statistically
Thermal response $\rho c_p V / hA$ 100-1000 s Fully resolved
Altitude change $H/v_z$ 1000-10000 s Adaptive stepping
Diurnal cycle Solar 86400 s Smooth forcing

Stiffness Detection and Handling

The simulator monitors for stiffness using the eigenvalue ratio:

def detect_stiffness(jacobian):
    """Detect system stiffness via eigenvalue analysis"""
    eigenvalues = np.linalg.eigvals(jacobian)
    lambda_max = np.max(np.abs(eigenvalues))
    lambda_min = np.min(np.abs(eigenvalues[eigenvalues != 0]))

    stiffness_ratio = lambda_max / lambda_min

    if stiffness_ratio > 1000:
        return True, stiffness_ratio
    return False, stiffness_ratio
Stiffness Sources: Near burst altitude, the balloon film stress approaches yield strength, creating a stiff mechanical response. The simulator automatically switches to implicit methods or reduces timestep when stiffness is detected.

Implementation Architecture

Core Integration Loop

class DormandPrince87Integrator:
    """High-order adaptive integrator for balloon dynamics"""

    def __init__(self, atol=1e-10, rtol=1e-10):
        self.atol = atol
        self.rtol = rtol
        self.setup_coefficients()

    def integrate(self, dynamics, state, t_start, t_end):
        """Integrate from t_start to t_end"""
        t = t_start
        h = self.estimate_initial_timestep(dynamics, state, t)

        trajectory = [state.copy()]
        times = [t]

        while t < t_end:
            # Adaptive step
            state_new, error, h_new = self.step(dynamics, state, t, h)

            if error <= 1.0:  # Accept step
                state = state_new
                t += h
                trajectory.append(state.copy())
                times.append(t)

            # Update timestep
            h = min(h_new, t_end - t)
            h = self.apply_timestep_limits(h, state)

        return times, trajectory

    def step(self, dynamics, state, t, h):
        """Single Dormand-Prince step with error estimation"""
        # Compute stages
        k = np.zeros((13, len(state)))

        k[0] = dynamics.compute_derivatives(t, state)

        for i in range(1, 13):
            t_i = t + self.c[i] * h
            state_i = state + h * np.sum(self.a[i,:i] * k[:i].T, axis=1)
            k[i] = dynamics.compute_derivatives(t_i, state_i)

        # 8th order solution
        state_8 = state + h * np.sum(self.b * k.T, axis=1)

        # 7th order solution for error
        state_7 = state + h * np.sum(self.b_hat * k.T, axis=1)

        # Error estimation
        error_vec = state_8 - state_7
        error = self.compute_error_norm(error_vec, state)

        # PI controller for timestep
        h_new = self.pi_timestep_control(h, error)

        return state_8, error, h_new

State Vector Management

class SimulationState:
    """Complete state vector for balloon simulation"""

    def __init__(self):
        # Primary state variables
        self.position = np.zeros(3)      # [x, y, z] in ECEF
        self.velocity = np.zeros(3)      # [vx, vy, vz] in ECEF
        self.temperature_film = 273.15   # Kelvin
        self.temperature_gas = 273.15    # Kelvin
        self.mass_gas = 0.0             # kg

        # Derived quantities (not integrated)
        self.pressure_gas = 101325       # Pa
        self.volume = 0.0               # m³
        self.radius = 0.0               # m
        self.altitude = 0.0             # m MSL

    def to_array(self):
        """Convert to array for integration"""
        return np.concatenate([
            self.position,
            self.velocity,
            [self.temperature_film],
            [self.temperature_gas],
            [self.mass_gas]
        ])

    def from_array(self, array):
        """Update from integration array"""
        self.position = array[0:3]
        self.velocity = array[3:6]
        self.temperature_film = array[6]
        self.temperature_gas = array[7]
        self.mass_gas = array[8]

Dense Output Interpolation

For smooth trajectory output and event detection, the integrator provides dense output:

$$y(t_n + \theta h) = y_n + h\sum_{i=1}^{13} b_i(\theta) k_i$$ where $\theta \in [0,1]$ and $b_i(\theta)$ are polynomial interpolants
def dense_output(self, t_eval, h, y_n, k_stages):
    """Evaluate solution at arbitrary points within step"""
    theta = (t_eval - self.t_n) / h

    # 7th order interpolation polynomials
    b_theta = np.zeros(13)
    for i in range(13):
        b_theta[i] = sum(self.d[i,j] * theta**j for j in range(8))

    return y_n + h * np.sum(b_theta * k_stages.T, axis=1)

Error Analysis & Stability

Local Truncation Error

The local truncation error for DP8(7) is:

$$LTE = Ch^9y^{(9)} + O(h^{10})$$ where $C \approx 10^{-7}$ for the DP8(7) coefficients

For typical balloon dynamics with $|y^{(9)}| \sim 10^{-3}$:

Timestep (s) Position Error (m) Velocity Error (m/s) Temperature Error (K)
1 10^-10 10^-11 10^-12
10 10^-1 10^-2 10^-3
60 10^6 10^5 10^4

Stability Region

The stability region for explicit Runge-Kutta methods is bounded. For DP8(7):

$$|h\lambda| \leq \beta \approx 5.5$$ where $\lambda$ are the eigenvalues of $\partial f/\partial y$

Stability Analysis for Balloon System

  1. Mechanical modes: $\lambda_{mech} \sim -\gamma/m \sim -0.1$ s^-1
  2. Thermal modes: $\lambda_{thermal} \sim -hA/(\rho c_p V) \sim -0.01$ s^-1
  3. Stable timestep: $h_{max} = 5.5/|\lambda_{max}| \sim 55$ s

Global Error Propagation

Over a complete trajectory, errors accumulate as:

$$\text{Global Error} \leq \frac{e^{L(t_f-t_0)} - 1}{L} \times \text{tol}$$ where $L$ is the Lipschitz constant
def estimate_global_error(self, trajectory, dynamics):
    """Estimate accumulated global error"""

    # Estimate Lipschitz constant
    L = self.estimate_lipschitz_constant(dynamics, trajectory)

    # Time span
    t_span = trajectory[-1].time - trajectory[0].time

    # Global error bound
    global_error = (np.exp(L * t_span) - 1) / L * self.atol

    return {
        'position_error': global_error * 1.0,  # m
        'velocity_error': global_error * 0.1,  # m/s
        'temperature_error': global_error * 0.01,  # K
        'relative_error': global_error / np.mean([s.altitude for s in trajectory])
    }

Performance Optimization

Computational Efficiency

The DP8(7) method requires 13 function evaluations per step. Performance optimizations include:

1. Vectorization

# Vectorized stage computation
k_stages = np.zeros((13, state_dim))
for i in range(13):
    # Compute all components simultaneously
    k_stages[i] = dynamics.derivatives_vectorized(t + c[i]*h,
                                                  state + h*a[i]@k_stages)

2. Jacobian Reuse

# Reuse Jacobian for multiple steps
if steps_since_jacobian > 5 or error > 0.1:
    jacobian = dynamics.compute_jacobian(state)
    steps_since_jacobian = 0

3. FSAL Property

First Same As Last: reuse $k_{13}$ from previous step as $k_1$

Memory Management

class MemoryEfficientIntegrator:
    """Minimize memory allocation during integration"""

    def __init__(self, state_dim):
        # Pre-allocate work arrays
        self.k_stages = np.zeros((13, state_dim))
        self.state_temp = np.zeros(state_dim)
        self.error_weights = np.zeros(state_dim)

    def step(self, dynamics, state, t, h):
        """Step with minimal allocation"""
        # Reuse pre-allocated arrays
        np.copyto(self.state_temp, state)

        # In-place operations
        for i in range(13):
            self.compute_stage_inplace(i, dynamics, t, h)

        # Update state in-place
        self.apply_step_inplace(state, h)

        return self.compute_error()

Parallel Integration

For ensemble simulations or sensitivity analysis:

def parallel_integrate(dynamics_list, states, t_span, n_cores=4):
    """Integrate multiple trajectories in parallel"""

    with multiprocessing.Pool(n_cores) as pool:
        # Prepare work packages
        work = [(dynamics, state, t_span) for dynamics, state
                in zip(dynamics_list, states)]

        # Parallel execution
        trajectories = pool.starmap(integrate_single, work)

    return trajectories

Special Case Handling

Burst Detection

Accurate burst detection requires special handling:

class BurstDetector:
    """Detect balloon burst using root finding"""

    def __init__(self, burst_diameter):
        self.burst_diameter = burst_diameter
        self.interpolator = None

    def check_burst(self, state_prev, state_curr, h):
        """Check for burst between states"""
        if state_curr.diameter >= self.burst_diameter:
            # Set up interpolation
            self.setup_interpolation(state_prev, state_curr, h)

            # Find exact burst time via root finding
            t_burst = self.find_burst_time()
            state_burst = self.interpolate_state(t_burst)

            return True, t_burst, state_burst
        return False, None, None

    def find_burst_time(self):
        """Binary search for burst time"""
        def burst_condition(theta):
            state = self.interpolate_state(theta)
            return state.diameter - self.burst_diameter

        # Bracket and solve
        theta_burst = scipy.optimize.brentq(burst_condition, 0, 1)
        return self.t_prev + theta_burst * self.h

Ground Impact

def detect_ground_impact(state, terrain_model):
    """Detect and handle ground contact"""
    ground_altitude = terrain_model.get_elevation(state.lat, state.lon)

    if state.altitude <= ground_altitude:
        # Compute impact conditions
        impact_velocity = np.linalg.norm(state.velocity)
        impact_angle = np.arccos(-state.velocity[2] / impact_velocity)

        return {
            'impact': True,
            'location': (state.lat, state.lon),
            'velocity': impact_velocity,
            'angle': np.degrees(impact_angle)
        }
    return {'impact': False}

Numerical Instability Recovery

def recover_from_instability(self, state, h):
    """Recover from numerical difficulties"""

    # Reduce timestep aggressively
    h_recovery = h * 0.1

    # Check for NaN or infinity
    if not np.all(np.isfinite(state.to_array())):
        raise SimulationError("Non-finite values detected")

    # Check physical constraints
    if state.temperature_gas < 0 or state.mass_gas < 0:
        raise SimulationError("Unphysical state detected")

    # Limit extreme velocities
    v_mag = np.linalg.norm(state.velocity)
    if v_mag > 1000:  # m/s - unrealistic for balloon
        state.velocity *= 100 / v_mag
        warnings.warn("Velocity limited due to instability")

    return h_recovery
Numerical Pitfalls:
  • Near burst: strain approaches infinity, requiring timestep reduction
  • Low altitude: dense atmosphere creates stiff drag forces
  • Dawn/dusk: rapid temperature changes challenge thermal integration
  • High winds: can create artificial acceleration in curved coordinates