diff --git a/apps/showcases/FreeSurface/raytracerVDB-1.py b/apps/showcases/FreeSurface/raytracerVDB-1.py
deleted file mode 100644
index a4cf0464e15c4debec39b20ae30963957cffa4e0..0000000000000000000000000000000000000000
--- a/apps/showcases/FreeSurface/raytracerVDB-1.py
+++ /dev/null
@@ -1,1019 +0,0 @@
-import pyopenvdb as vdb
-import os
-from math import floor
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.colors as colors
-from matplotlib.animation import FuncAnimation
-from typing import Tuple, List
-import scipy.ndimage as ndimage
-from scipy.ndimage import gaussian_filter
-from scipy.spatial.transform import Rotation as Ro
-from scipy.spatial.transform import Slerp
-from scipy.interpolate import PchipInterpolator
-from sympy.printing.tree import print_node
-
-
-def runRayTracer(x_num, y_num, z_num, fill_levels, current_timestep):
-
-    print("current_timestep", current_timestep)
-    # fill_levels = np.clip(fill_levels, 0, 1)
-    fill_levels = fill_levels.astype(np.float64)
-
-    # Define the tolerance
-    tolerance = 1e-8
-
-    # Check if any value in fill_levels is outside the [0, 1] range
-    out_of_range = np.logical_or(fill_levels < -tolerance, fill_levels > 1 + tolerance)
-    if np.any(out_of_range):
-        out_of_range_values = fill_levels[out_of_range]
-        out_of_range_indices = np.where(out_of_range)
-
-        for idx, value in zip(zip(*out_of_range_indices), out_of_range_values):
-            print(f"Index: {idx}, Value: {value}")
-
-        # raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    # Check for fill levels within the range [0, 1]
-    in_range = np.logical_and(fill_levels > 0, fill_levels < 1)
-    in_range_values = fill_levels[in_range]
-    in_range_indices = np.where(in_range)
-
-    # print("Fill level values within range (0 to 1):")
-    # for idx, value in zip(zip(*in_range_indices), in_range_values):
-    # if idx == (np.int64(309),):
-    # print(f"Index: {idx}, Value: {value}")
-
-    # Constants
-    pi: float = np.pi  # Mathematical constant π
-    e: float = 1.60217662e-19  # Elementary charge in Coulombs
-    beam_current: float = 3e-3  # Beam current in Amperes
-    dwell_time: float = 0.32e-6  # Dwell time in seconds
-    A: float = 21.5  # Approximation for Ti6Al4V
-    # Ti6Al4V = Ti6Al4V.create_Ti6Al4V(T)
-    # A: float = Ti6Al4V.atomic_number
-    # print('Ti6Al4V.elements: ', Ti6Al4V.elements)
-    # print('Ti6Al4V.composition: ', Ti6Al4V.composition)
-    # print('A: ', A)
-    dx: float = 3e-6  # Cell size in meters
-    dy: float = dx  # Cell size in meters
-    dz: float = dx  # Cell size in meters
-    threshold: float = 0  # Beam profile threshold
-    # The voltage at detector d can be computed using the transimpedance T (in units of V /A ) of a virtual amplifier in the context of the model
-    transimpedance: float = 1e5  # Transimpedance in Volts per Ampere
-    eV_threshold = 50  # Threshold energy in eV
-    threshold_energy = eV_threshold * e  # Threshold energy in joules
-    # Define the work distance (height of the electron beam gun)
-    # w_d = 12e-3  # Work distance in meters (272 mm)
-
-    # Domain setup (Section 2.1.1)
-    # Define the 3D domain for the simulation
-    x_min, x_max = -0.5 * x_num * dx, 0.5 * x_num * dx  # -10 mm to 10 mm
-    # print('x_min, x_max:', x_min, x_max)
-    y_min, y_max = -0.5 * y_num * dy, 0.5 * y_num * dy  # -10 mm to 10 mm
-    # print('y_min, y_max:', y_min, y_max)
-    z_min, z_max = 0, z_num * dz
-    # print('z_min, z_max:', z_min, z_max)
-    # x_num = int((x_max - x_min) / dx) + 1
-    # y_num = int((y_max - y_min) / dy) + 1
-    # z_num = int((z_max - z_min) / dz) + 1
-    # print(f"Domain dimensions: x={x_num}, y={y_num}, z={z_num}")
-    # domain = np.zeros((z_num, y_num, x_num))  # (z, y , x)
-    # print('domain shape: ', np.shape(domain))
-    # domain[0:2, :, :] = 1  # Solid cells
-    # domain[2, :, :] = 0.5  # Interface layer
-    fill_levels_reshaped = np.reshape(fill_levels, (z_num, y_num, x_num))
-    # fill_levels_reshaped = fill_levels
-    # print('fill_levels_reshaped:', fill_levels_reshaped)
-    out_of_range_fill_levels = np.logical_or(fill_levels_reshaped < -tolerance, fill_levels_reshaped > 1 + tolerance)
-    if np.any(out_of_range_fill_levels):
-        out_of_range_fill_level_values = fill_levels_reshaped[out_of_range_fill_levels]
-        out_of_range_fill_level_indices = np.where(out_of_range_fill_levels)
-
-        for idx, value in zip(zip(*out_of_range_fill_level_indices), out_of_range_fill_level_values):
-            print(f"Index: {idx}, Value: {value}")
-        raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    sigma = 0.0
-    # Different sigma for each dimension
-    # sigma = [0.0, 1.0, 0.5]  # [z, y, x]
-    fill_levels_smoothed = ndimage.gaussian_filter(fill_levels_reshaped, sigma=sigma, mode='nearest', radius=1)
-
-    fill_levels_smoothed = np.clip(
-        fill_levels_smoothed,
-        a_min=np.float64(0.0),
-        a_max=np.float64(1.0)
-    )
-
-    x = 242
-    y = 189
-    # Print fill levels at specific position
-    print(f"\nFill levels at ({x}, {y}):")
-    print("z_index | fill_level")
-    print("--------------------")
-    column = fill_levels_smoothed[:, y, x]  # Get column at (157, 196)
-    for z_idx, fill_level in enumerate(column):
-        if 166 <= z_idx <= 176:  # Print relevant range
-            print(f"    {z_idx} | {fill_level:.6f}")
-
-    # Check if values are still in bounds
-    if not (np.all(fill_levels_smoothed >= 0) and np.all(fill_levels_smoothed <= 1)):
-        raise ValueError("Smoothed fill levels out of bounds!")
-
-
-    def interpolate_normals(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """Interpolate between two normals using SLERP"""
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-
-        # Ensure normalized vectors
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Thresholds for special cases
-        PARALLEL_THRESHOLD = 0.9999
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            return normalize_vector((1.0 - t) * _normal_below + t * _normal_above)
-
-        # Case 2: Nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            angle = np.pi * t
-            return normalize_vector(_normal_below * np.cos(angle) + perp * np.sin(angle))
-
-        # Case 3: Standard SLERP
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        return normalize_vector(scale0 * _normal_below + scale1 * _normal_above)
-
-
-    def interpolate_fill_level_and_normal(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check for interface crossing (VOF method)
-                '''crosses_interface = (
-                        (current_fill >= INTERFACE_THRESHOLD - TOLERANCE >= next_fill) or
-                        (current_fill <= INTERFACE_THRESHOLD + TOLERANCE <= next_fill)
-                )'''
-                crosses_interface = (
-                    (current_fill >= (INTERFACE_THRESHOLD - TOLERANCE) and
-                     next_fill <= (INTERFACE_THRESHOLD + TOLERANCE))
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-                    # Linear interpolation with better numerical stability
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Calculate interpolation parameter
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        # t_interface = np.clip(t_interface, 0.0, 1.0)
-                        _z_interp = z + t_interface
-
-                    print("_z_interp:", _z_interp)
-                    _z_height = (_z_interp + 0.5) * dz
-                    print("_z_height:", _z_height)
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    # Compute surface normals (Section 2.1.1)
-    n_S = compute_surface_normals_sobel(fill_levels_smoothed)
-    # print('np.shape(n_S): ', np.shape(n_S))
-
-    # Beam profile (Section 2.1)
-    # Define the Gaussian beam profile
-    sigma: float = 500e-6 / (4 * dx)  # Beam profile sigma
-    x = np.arange(-np.floor(min(x_num//4, 2*sigma)), 1+np.floor(min((x_num//4), 2*sigma)))
-    # print('x:', x)
-    y = np.arange(-floor(min(y_num//4, 2*sigma)), 1+floor(min((y_num//4), 2*sigma)))
-    # print('y:', y)
-    X, Y = np.meshgrid(x, y)
-    beam_profile = np.exp(-(X ** 2 + Y ** 2) / (2 * sigma ** 2))
-    beam_profile /= np.sum(beam_profile)  # Normalize beam profile
-    print('beam_profile shape: ', np.shape(beam_profile))
-
-    beam_size = np.shape(beam_profile)[0] * np.shape(beam_profile)[1]
-    # print('Beam size:', beam_size)
-
-    # Detector setup (Section 2.1.1)
-    # Define positions of the detectors
-    detector_height = 4 * z_max  # Detector height in meters (272 mm)
-    detector_spacing_x = 1.5 * dx * x_num  # Detector spacing in meters (127 mm)
-    detector_spacing_y = detector_spacing_x  # 1.5 * dy * y_num  # Detector spacing in meters (127 mm)
-    detector_diameter = 4 * dx  # Detector diameter in meters (50 mm)
-
-    # Define detector positions relative to the origin
-    detector_positions = np.array([
-        [0.5 * detector_spacing_x, 0, detector_height],
-        [-0.5 * detector_spacing_x, 0, detector_height],
-        [0, 0.5 * detector_spacing_y, detector_height],
-        [0, -0.5 * detector_spacing_y, detector_height]
-    ], dtype=np.float64)
-    # print('detector_positions:', detector_positions)
-
-
-    # Define detector vertices for solid angle calculation
-    def get_detector_vertices(detector_position: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
-        # Calculate the side length of the detector
-        side_length : float = np.sqrt(3) * (detector_diameter / 2)
-        height : float = np.sqrt(3) * (side_length / 2)
-
-        # Determine the orientation based on the detector position
-        if np.allclose(detector_position[:2], [0, 0]):
-            raise ValueError("Detector position cannot be at the origin (0, 0, 0)")
-        elif detector_position[0] > 0:  # Right detector
-            _v1 = detector_position + np.array([2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-height / 3, side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-height / 3, -side_length / 2, 0], dtype=np.float64)
-        elif detector_position[0] < 0:  # Left detector
-            _v1 = detector_position + np.array([-2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([height / 3, -side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([height / 3, side_length / 2, 0], dtype=np.float64)
-        elif detector_position[1] > 0:  # Back detector
-            _v1 = detector_position + np.array([0, 2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-side_length / 2, -height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([side_length / 2, -height / 3, 0], dtype=np.float64)
-        elif detector_position[1] < 0:  # Front detector
-            _v1 = detector_position + np.array([0, -2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([side_length / 2, height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-side_length / 2, height / 3, 0], dtype=np.float64)
-        else:
-            raise ValueError("Invalid detector position. Must be in one of the four expected positions.")
-
-        return _v1, _v2, _v3
-
-
-    # Compute solid angle using Equation A1 (Appendix A)
-    def compute_solid_angle(vector1: np.ndarray, vector2: np.ndarray, vector3: np.ndarray) -> float:
-        # Input validation
-        if not isinstance(vector1, np.ndarray) or not isinstance(vector2, np.ndarray) or not isinstance(vector3, np.ndarray):
-            raise ValueError("Inputs must be numpy arrays")
-        if vector1.shape != (3,) or vector2.shape != (3,) or vector3.shape != (3,):
-            raise ValueError("Inputs must be 3D vectors")
-        if not np.isfinite(vector1).all() or not np.isfinite(vector2).all() or not np.isfinite(vector3).all():
-            raise ValueError("Inputs must be finite")
-
-        # Convert to high precision once
-        vectors = [vec.astype(np.float64) for vec in (vector1, vector2, vector3)]
-        cross_product = np.cross(vectors[1], vectors[2])
-        # Check if the cross product is nearly zero
-        if np.linalg.norm(cross_product) < 1e-16:
-            raise ValueError("Vectors are nearly coplanar")
-
-        # Compute solid angle
-        try:
-            # Compute the numerator of the solid angle formula
-            numerator: float = np.dot(vectors[0], cross_product)
-            # Check if the numerator is nearly zero
-            if np.abs(numerator) < 1e-16:
-                raise ValueError("Vectors are nearly parallel")
-
-            norms = [np.linalg.norm(v) for v in vectors]
-
-            # Compute the denominator of the solid angle formula
-            denominator = (
-                    norms[0] * norms[1] * norms[2] +
-                    np.dot(vectors[0], vectors[1]) * norms[2] +
-                    np.dot(vectors[0], vectors[2]) * norms[1] +
-                    np.dot(vectors[1], vectors[2]) * norms[0]
-            )
-
-            # Compute the solid angle in radians
-            solid_angle = 2 * np.arctan2(numerator, denominator)
-            # print("Solid angle:", solid_angle)
-            return solid_angle
-        except ZeroDivisionError:
-            raise ValueError("Error computing solid angle: division by zero")
-        except np.linalg.LinAlgError:
-            raise ValueError("Error computing solid angle: linear algebra error")
-
-
-    # BSE coefficient functions (Equations 11 and 10, Section 2.1.2)
-    def eta_0(_A: float) -> float:
-        """Equation 11 from the paper"""
-        _eta_0: float = -0.0254 + 0.016 * _A - 1.86e-4 * _A ** 2 + 8.3e-7 * _A ** 3
-        print('eta_0: ', _eta_0)
-        return _eta_0
-
-
-    # BSE Coefficient for single atomic targets, found to hold in the range of 10–100 keV
-    def eta(theta: float, _A: float) -> float:
-        """Equation 10 from the paper"""
-        B: float = 0.89
-        _eta: float = B * (eta_0(_A) / B) ** np.cos(theta)
-        print('eta: ', _eta)
-        return _eta
-
-
-    '''
-    # Compute the weighted average BSE coefficient for the alloy
-    def compute_weighted_eta(theta: float, alloy: Alloy) -> float:
-        """Equation 12 from the paper"""
-        weighted_eta: float = 0
-        for element, weight_fraction in zip(alloy.elements, alloy.composition):
-            # print('element: ', element)
-            # print('weight_fraction: ', weight_fraction)
-            _A: float = element.atomic_number
-            # print('A: ', _A)
-            eta_i: float = eta(theta, _A)
-            # print('eta_i: ', eta_i)
-            weighted_eta += weight_fraction * eta_i
-            # print('weighted_eta: ', weighted_eta)
-        return weighted_eta
-    '''
-
-
-    # Correction function C(theta) (Equation 14, Section 2.1.3)
-    def C(theta: float) -> float:
-        print("theta1:", theta)
-        """Equation 14 from the paper"""
-        theta = np.rad2deg(theta)  # Ensure angle is in degrees for C function
-        _C: float = 1.01 - 4.06 * theta + 1.36e-5 * theta ** 2 - 1.71e-6 * theta ** 3
-        print('C: ', _C)
-        # Check if C is negative and raise an exception
-        if _C < 0:
-            raise ValueError(f"C function returned a negative value for theta: {theta}. "
-                             f"Please check the input and the C function implementation.")
-        return _C
-
-
-    # Scattering function g_BSE (Equation 13, Section 2.1.3)
-    def g_BSE(_theta: float, _alpha: float, _beta: float) -> float:
-        print("_theta, _alpha, _beta:", _theta, _alpha, _beta)
-        """Equation 13 from the paper"""
-        # k: float = np.rad2deg(_theta) / 13  # Correct implementation as per the paper
-        # print("k: ", k)
-        diffusive_part: float = (eta_0(A) / pi) * np.cos(_alpha)
-        print(np.cos(_alpha))
-        print("diffusive_part:", diffusive_part)
-        # reflective_part: float = ((eta(_theta, A) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-        # if np.abs(reflective_part) < 1e-10:
-        # reflective_part = 0
-        # print("reflective_part:", reflective_part)
-
-        # Compute a weighted average BSE coefficient for the alloy
-        # reflective_part: float = ((compute_weighted_eta(_theta, Ti6Al4V) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-
-        # print('g_BSE: ', diffusive_part + reflective_part)
-        return diffusive_part  #+ reflective_part
-
-
-    # Calculate the beam direction vector p_E
-    def calculate_p_E(_beam_x, _beam_y):
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        # Calculate the vector from the beam gun to the beam location
-        beam_vector = np.array([_beam_x * dx, _beam_y * dy, -detector_height], dtype=np.float64)
-        return normalize_vector(beam_vector)
-
-    def calculate_p_E1(_beam_x, _beam_y, _height):
-        """Calculate normalized beam direction vector from gun to surface point
-
-        Args:
-            _beam_x: Beam x-position relative to center (in cells)
-            _beam_y: Beam y-position relative to center (in cells)
-            _height: Height difference between gun and surface point (m)
-        """
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        print("_height:", _height)
-
-        # Calculate vector components
-        x_component = _beam_x * dx  # Convert cells to meters
-        y_component = _beam_y * dy  # Convert cells to meters
-        z_component = _height       # Already in meters
-
-        # Create beam vector (from gun to surface point)
-        beam_vector = np.array([x_component, y_component, z_component], dtype=np.float64)
-
-        # Verify downward direction
-        if normalize_vector(beam_vector)[2] > 0:
-            raise ValueError("Beam vector pointing upward")
-
-        # Normalize and return
-        return normalize_vector(beam_vector)
-
-    def calculate_p_E2(_beam_x, _beam_y, _height):
-        """Calculate normalized beam direction vector from gun to surface point
-
-        Args:
-            _beam_x: x-position relative to center (in cells)
-            _beam_y: y-position relative to center (in cells)
-            _height: height difference between gun and surface (in meters)
-
-        Returns:
-            np.ndarray: Normalized beam direction vector
-        """
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        print("_height:", _height)
-
-        # Calculate beam vector components (from gun to surface point)
-        beam_vector = np.array([
-            _beam_x * dx,    # x component
-            _beam_y * dy,    # y component
-            _height          # z component (already in meters)
-        ], dtype=np.float64)
-
-        # Normalize the vector
-        norm = np.linalg.norm(beam_vector)
-        if norm < 1e-10:
-            raise ValueError("Beam vector magnitude too small")
-
-        normalized_vector = beam_vector / norm
-
-        # Verify the vector is pointing downward (negative z)
-        if normalized_vector[2] > 0:
-            raise ValueError("Beam vector must point downward (negative z)")
-
-        return normalized_vector
-
-
-    # Calculate phi as the angle between p_E and the negative z-axis
-    def calculate_phi(_p_E) -> float:
-        z_axis = np.array([0.0, 0.0, -1.0], dtype=np.float64)
-
-        # Check if _p_E is approximately equal to the negative z-axis
-        '''if np.allclose(_p_E, z_axis, rtol=1e-8, atol=1e-8):
-            _phi_rad = 0.0
-        else:
-            # Calculate the angle between _p_E and the z-axis using arccos
-            _phi_rad = calculate_angle(_p_E, z_axis)'''
-        _phi_rad = calculate_angle(_p_E, z_axis)
-        print(f"calculate_phi({_p_E}, {_phi_rad}):")
-        return _phi_rad
-
-
-    def set_beam_movement(_x_center, _y_center, _x_offset, _y_offset, _movement_type):
-        if _movement_type == 'X':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            _beam_y_start = _y_center + _y_offset
-            _beam_y_end = _y_center + _y_offset + 1
-        elif _movement_type == 'Y':
-            _beam_x_start = _x_center + _x_offset
-            _beam_x_end = _x_center + _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        elif _movement_type == 'XY':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        else:
-            raise ValueError("movement_type must be 'X' or 'Y' or 'XY'")
-
-        return _beam_x_start, _beam_x_end, _beam_y_start, _beam_y_end
-
-
-    # Initialize arrays to store electron counts
-    electrons_per_detector_per_step = np.zeros((x_num, y_num, len(detector_positions)))
-    total_electrons_per_step = np.zeros((x_num, y_num))
-
-    # Additional arrays to capture detailed information
-    electrons_per_detector_per_beam_profile = np.zeros(
-        (beam_profile.shape[0], beam_profile.shape[1], len(detector_positions)))
-    total_electrons_per_beam_profile = np.zeros((beam_profile.shape[0], beam_profile.shape[1]))
-
-    # Calculate the center indices
-    x_center = x_num // 2
-    y_center = y_num // 2
-    # print('x_center, y_center:', x_center, y_center)
-
-    x_offset: int = 0
-    y_offset: int = 0
-    movement_type: str = 'Y'
-
-    # Beam X-Movement
-    # beam_x_start, beam_x_end = x_center - x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center + y_offset, y_center + y_offset + 1
-
-    # Beam Y-Movement
-    # beam_x_start, beam_x_end = x_center + x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center - y_offset, y_center + y_offset + 1
-
-    # For X or Y Movement
-    beam_x_start, beam_x_end, beam_y_start, beam_y_end = set_beam_movement(
-        x_center, y_center, x_offset, y_offset, movement_type)
-
-    # Determine the step size based on the direction of movement
-    step_x = 1 if beam_x_start <= beam_x_end else -1
-    step_y = 1 if beam_y_start <= beam_y_end else -1
-
-    # print(beam_x_start, beam_x_end, beam_y_start, beam_y_end, step_x, step_y)
-
-
-    z_heights = np.zeros(beam_size)
-    fill_level_b = np.zeros(beam_size)
-    fill_level_a = np.zeros(beam_size)
-    normals = np.zeros((beam_size, 3))
-    normals_b = np.zeros((beam_size, 3))
-    normals_a = np.zeros((beam_size, 3))
-    cell_counter = 0
-
-
-    def normalize_vector(vector: np.ndarray) -> np.ndarray:
-        """Normalize a 3D vector."""
-        vector = vector.astype(np.float64)  # Convert to float64
-        norm = np.linalg.norm(vector)
-        if norm < 1e-18:  # Avoid division by zero
-            return vector  # Return original if nearly zero
-        return vector / norm  # Normalize
-
-
-    def calculate_angle(_v1: np.ndarray, _v2: np.ndarray) -> float:
-        """Calculate the angle between two normalized vectors."""
-        _v1 = normalize_vector(_v1)
-        _v2 = normalize_vector(_v2)
-
-        dot_product = np.clip(np.dot(_v1, _v2), -1.0, 1.0)
-
-        return np.arccos(dot_product)  # Return angle in radians
-
-
-    # Main simulation loop to iterate over the simulation domain
-    for beam_x in range(beam_x_start, beam_x_end, step_x):
-        for beam_y in range(beam_y_start, beam_y_end, step_y):
-            beam_loc = (beam_x, beam_y)
-            print(f"Beam center is at domain location: {beam_loc}")
-
-            # Calculate the beam direction vector p_E
-            # >> p_E = calculate_p_E(beam_x - x_center, beam_y - y_center)
-            # >> print('p_E: ', p_E)
-
-            # Calculate phi as the angle between p_E and the negative z-axis
-            # phi_rad = np.arctan2(np.sqrt(dx**2 + dy**2), w_d)
-            # >> phi_rad = calculate_phi(p_E)
-            # print(f"phi_rad: {phi_rad}")
-
-            # Reset counters for this beam location
-            total_electrons: int = 0
-            electrons_per_detector = np.zeros(len(detector_positions))
-
-            # Iterate over the beam profile
-            for profile_x in [125]:
-            # for profile_x in range(beam_profile.shape[0]):
-            # for profile_x in [beam_profile.shape[0]//2 + 0]:
-                for profile_y in [72]:
-                # for profile_y in range(beam_profile.shape[1]):
-                # for profile_y in [beam_profile.shape[1]//2 + 0]:
-                    print(f"Beam profile shape ({profile_x}, {profile_y})")
-                    domain_x = beam_loc[0] - beam_profile.shape[0] // 2 + profile_x
-                    domain_y = beam_loc[1] - beam_profile.shape[1] // 2 + profile_y
-                    print(f"Domain ({domain_x}, {domain_y})")
-
-                    # p_E = calculate_p_E(domain_x - x_center, domain_y - y_center)
-                    # print('p_E: ', p_E)
-                    # phi_rad = calculate_phi(p_E)
-
-                    if (0 <= domain_x < x_num) and (0 <= domain_y < y_num):
-                        if beam_profile[profile_x, profile_y] > threshold:
-                            print('finding interface_z_indices')
-                            # print('interface_z_indices:', interface_z)
-                            z_interp, z_height, fill_level_below, fill_level_above, normal_interp, normals_below, normals_above \
-                                = interpolate_fill_level_and_normal(fill_levels_smoothed, n_S, domain_y, domain_x)
-                            if z_interp is not None:
-                                z_heights[cell_counter] = z_height
-                                fill_level_b[cell_counter] = fill_level_below
-                                fill_level_a[cell_counter] = fill_level_above
-                                normals[cell_counter] = normal_interp
-                                normals_b[cell_counter] = normals_below
-                                normals_a[cell_counter] = normals_above
-                                cell_counter += 1
-                                n_S_local = normal_interp
-                                print('n_S_local:', n_S_local)
-                                if not np.allclose(np.linalg.norm(n_S_local), 1.0, atol=1e-5):
-                                    raise ValueError(f"n_S_local is not normalized, {np.linalg.norm(n_S_local)}")
-
-                                print("detector_height:", detector_height)
-                                print("z_height:", z_height)
-                                p_E = calculate_p_E1(domain_x - x_center, domain_y - y_center, z_height-detector_height)
-                                print('p_E: ', p_E)
-
-                                theta_local = calculate_angle(-p_E, n_S_local)
-                                print('theta_local:', theta_local)
-                                # if theta_local < 0.0 or theta_local > 0.011:
-                                # raise ValueError(f"theta_local value should be 0.0, found {theta_local}")
-                                # theta_local: 0.016
-
-                                scattering_point = np.array([
-                                    (domain_x - beam_x) * dx,
-                                    (domain_y - beam_y) * dy,
-                                    z_height
-                                ], dtype=np.float64)
-                                print(f'scattering_point ({domain_x}, {domain_y}, {z_interp}):', scattering_point)
-
-                                for det_idx, det_pos in enumerate(detector_positions):
-                                    # print(f"Detector {det_idx + 1} position: {det_pos}")
-                                    det_idx: int
-                                    # det_pos: np.ndarray[float]
-                                    print(f'det_pos[{det_idx + 1}]: {det_pos}')
-
-                                    # Calculate the vector from the scattering point to the detector
-                                    det_vec = det_pos - scattering_point
-                                    det_vec = normalize_vector(det_vec)
-                                    print(f'det_vec[{det_idx + 1}]: {det_vec}')
-                                    if not np.allclose(np.linalg.norm(det_vec), 1.0):
-                                        raise ValueError("det_vec is not normalized.")
-
-                                    # alpha = np.arccos(np.clip(np.dot(det_vec, n_S_local), -1.0, 1.0))
-                                    alpha = calculate_angle(det_vec, n_S_local)
-                                    print(f'alpha: {alpha}')
-
-                                    if alpha > np.pi / 2:
-                                        continue
-
-                                    p_E_reflected = p_E - 2.0 * np.dot(p_E, n_S_local) * n_S_local
-                                    # p_E_reflected = np.where(np.abs(p_E_reflected) < 1e-8, 0.0, p_E_reflected)
-                                    p_E_reflected = normalize_vector(p_E_reflected)
-                                    print(f'p_E_reflected: {p_E_reflected}')
-
-                                    # beta = np.arccos(np.clip(np.dot(p_E_reflected, det_vec), -1.0, 1.0))
-                                    beta = calculate_angle(p_E_reflected, det_vec)
-                                    print(f'beta: {beta}')
-
-                                    # Apply energy threshold
-                                    # electron_energy = N_0 * e * g_bse_value
-                                    # print('electron_energy: ', electron_energy)
-
-                                    # Calculate the initial energy of the primary electrons (E_0)
-                                    V_acc = 60e3  # Accelerating voltage in Volts
-                                    E_0 = V_acc * e  # Initial energy of primary electrons in joules
-
-                                    # Calculate the energy of the backscatter electrons
-                                    E_BSE = E_0 * eta(theta_local, A)
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # E_BSE = E_0 * compute_weighted_eta(theta_local, Ti6Al4V)
-                                    # print('E_BSE: ', E_BSE)
-
-                                    # Bifurcation logic for PE and SE
-                                    # if E_BSE > threshold_energy:
-                                    # Backscatter electrons (BSE)
-                                    # Compute scattering function g_bse_value
-                                    g_bse_value = g_BSE(theta_local, alpha, beta)
-                                    print(f'g_bse_value: {g_bse_value}')
-
-                                    # Ensure g_bse_value is non-negative
-                                    if g_bse_value <= 0:
-                                        raise ValueError(f"g_bse_value is {g_bse_value} (zero negative), check calculations.")
-
-                                    # Compute solid angle (Equation A1)
-                                    v1, v2, v3 = get_detector_vertices(det_pos)
-                                    normal = np.cross(v2 - v1, v3 - v1)
-                                    normal = normalize_vector(normal)
-
-                                    dOmega = compute_solid_angle(v1 - scattering_point,
-                                                                 v2 - scattering_point,
-                                                                 v3 - scattering_point)
-                                    print(f"Detector {det_idx + 1} solid angle: {dOmega}")
-
-                                    # Compute number of electrons collected by the detector (Equation A2)
-                                    # The beam ray stays on each grid point ij for the dwell time t_d and distributes N_0 PE into it
-                                    N_0 = beam_current * dwell_time / e  # Equation 3, Section 2.1.1
-                                    print("N_0:", N_0)
-
-                                    # There are N1 electrons in the chamber after the 1st scattering event
-                                    N_1 = N_0 * eta(theta_local, A)  # Equation 4, Section 2.1.1
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # N_1 = N_0 * compute_weighted_eta(theta_local, Ti6Al4V)  # Equation 4, Section 2.1.1
-                                    print("N_1:", N_1)
-
-                                    # The number of electrons N_1_d collected by a general detector d
-                                    N_1_d = N_1 * np.sum(g_bse_value * dOmega)  # Equation 5, Section 2.1.1
-
-                                    # Accumulate electrons per detector for this beam profile point
-                                    electrons_per_detector_per_beam_profile[profile_x, profile_y, det_idx] += N_1_d
-
-                                    # Accumulate electrons per detector for this domain point
-                                    electrons_per_detector_per_step[beam_x, beam_y, det_idx] += N_1_d
-
-                                    # Add to total electrons for this beam profile point
-                                    total_electrons_per_beam_profile[profile_x, profile_y] += N_1_d
-
-                                    # Add to total electrons for this domain point
-                                    total_electrons_per_step[beam_x, beam_y] += N_1_d
-
-                                    # Accumulate electrons per detector
-                                    electrons_per_detector[det_idx] += N_1_d
-
-                                    # Add to total electrons
-                                    total_electrons += N_1_d
-
-                                    # print(f"After addition: electrons_per_detector[{det_idx + 1}]: {electrons_per_detector[det_idx]}")
-                            else:
-                                print(f"No interface found at ({domain_x}, {domain_y})")
-
-                    # Print detailed information for this beam profile point
-                    # print(f"Electrons per detector at this beam profile point ({profile_x}, {profile_y}): {electrons_per_detector_per_beam_profile[profile_x, profile_y]}")
-                    # print(f"Total electrons at this beam profile point ({profile_x}, {profile_y}): {total_electrons_per_beam_profile[profile_x, profile_y]}")
-
-            # Print detailed information for this domain point after iterating over the beam profile
-            # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-            # print(f"Total electrons at this domain point ({beam_x}, {beam_y}): {total_electrons_per_step[beam_x, beam_y]}")
-
-            # print("Shape of electrons_per_detector_per_step:", electrons_per_detector_per_step.shape)
-            # print("Number of lines:", len(lines))
-
-    # Compute final results
-    total_electrons_all_steps = np.sum(total_electrons_per_step)
-    total_electrons_per_detector = np.sum(electrons_per_detector_per_step, axis=(0, 1))
-
-    # Print final results
-    # print(f"Total electrons hitting all detectors: {total_electrons_all_steps}")
-    # for i, electrons in enumerate(total_electrons_per_detector):
-    # print(f"Total electrons hitting detector {i + 1}: {electrons}")
-
-    # for beam_x in range(beam_x_start, beam_x_end, step_x):
-    # for beam_y in range(beam_y_start, beam_y_end, step_y):
-    # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-    # print('end simulation')
-    # print(electrons_per_detector_per_step)
-
-    # print("total_electrons:", total_electrons, "at timestep", current_timestep)
-    # print("electrons_per_det:", electrons_per_det, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc:", electrons_per_beam_loc, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc_per_det:", electrons_per_beam_loc_per_det, "at timestep", current_timestep)
-    return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, normals, normals_b, normals_a
-
-def read_fill_levels(filepath):
-    """Read only the FillingFrac grid from VDB file"""
-    try:
-        # Try to read just the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        print(f"Grid dimensions: {dims}")
-
-        # Create array
-        fill_array = np.zeros(dims, dtype=np.float64)
-
-        # Copy data
-        grid.copyToArray(fill_array)
-
-        # Transpose to ray tracer's expected order (z, y, x)
-        fill_array = np.transpose(fill_array, (2, 1, 0))
-
-        # Create standardized array with 256 z cells
-        standardized_array = np.zeros((256, dims[1], dims[0]), dtype=np.float64)
-
-        # Copy data from original array to standardized array
-        standardized_array[:fill_array.shape[0], :, :] = fill_array
-
-        # Apply Gaussian smoothing
-        sigma = 1.0
-        standardized_array_smoothed = ndimage.gaussian_filter(standardized_array, sigma=sigma, mode='nearest', radius=1)
-
-        # Print values
-        x = 242
-        y = 189
-        print(f"\nFill levels at ({x}, {y}):")
-        print("z_index | fill_level")
-        print("-" * 20)
-
-        # Get fill levels
-        fill_levels = standardized_array_smoothed[:, y, x]
-        valid_levels = fill_levels[(fill_levels > 0) & (fill_levels < 1)]
-
-        if len(valid_levels) > 0:
-            for z, val in enumerate(fill_levels):
-                if 0 <= val <= 1:
-                    print(f"{z:7d} | {val:.6f}")
-        else:
-            print("No fill level found between 0 and 1")
-
-        return standardized_array_smoothed
-
-    except Exception as e:
-        print(f"Error reading {filepath}")
-        print(f"Error details: {str(e)}")
-        try:
-            metadata = vdb.readMetadata(filepath)
-            print("\nFile metadata:")
-            for key, value in metadata.items():
-                print(f"{key}: {value}")
-        except Exception as meta_e:
-            print(f"Error reading metadata: {str(meta_e)}")
-        return None
-
-def process_single_timestep(filepath, timestep):
-    """Process a single VDB file and run ray tracer"""
-    try:
-        # Read the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        x_num, y_num = dims[0], dims[1]
-        z_num = 256  # dims[2] # Fixed z dimension
-        print(f"Domain dimensions: {x_num} x {y_num} x {z_num}")
-
-        # Read fill levels
-        fill_levels = read_fill_levels(filepath)
-        if fill_levels is None:
-            raise ValueError("Could not read fill levels from VDB file")
-
-        if fill_levels is not None:
-            print("\nSuccessfully read fill levels")
-            print(f"Array shape: {np.shape(fill_levels)}")
-
-            try:
-                # Convert to numpy array if not already
-                fill_array = np.asarray(fill_levels)
-
-                # Get non-zero elements
-                non_zero = fill_array[fill_array > 0]
-
-                # Get interface cells (0 < fill_level < 1)
-                interface_cells = fill_array[(fill_array > 0) & (fill_array < 1)]
-
-                # Print statistics
-                if len(non_zero) > 0:
-                    print("\nNon-zero cells statistics:")
-                    print(f"Number of non-zero cells: {len(non_zero)}")
-                    print(f"Min non-zero value: {np.min(non_zero):.6f}")
-                    print(f"Max value: {np.max(fill_array):.6f}")
-
-                if len(interface_cells) > 0:
-                    print("\nInterface cells statistics:")
-                    interface_percentage = (len(interface_cells) * 100.0) / len(non_zero)
-                    print(f"Number of interface cells: {len(interface_cells)} ({interface_percentage:.3f}%)")
-                    print(f"Min interface value: {np.min(interface_cells):.6f}")
-                    print(f"Max interface value: {np.max(interface_cells):.6f}")
-                    print(f"Mean interface value: {np.mean(interface_cells):.6f}")
-                else:
-                    print("\nNo interface cells found (no fill levels between 0 and 1)")
-
-            except Exception as e:
-                print(f"Error processing array: {str(e)}")
-
-        # Run ray tracer
-        print(f"\nProcessing timestep {timestep}")
-        electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a = runRayTracer(
-            x_num,
-            y_num,
-            z_num,
-            # fill_levels.flatten(),
-            fill_levels,
-            timestep
-        )
-
-        return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a
-
-    except Exception as e:
-        print(f"Error processing timestep {timestep}: {str(e)}")
-        raise
-
-def main():
-    """Main function to process single VDB file and run ray tracer"""
-    # Specific VDB file path
-    filepath = "/local/ca00xebo/softwares/KiSSAM/Markl-plate/geometry-000006200.vdb"
-    timestep = 0  # or extract from filename if needed
-
-    try:
-        print(f"\nProcessing file: {os.path.basename(filepath)}")
-
-        # Process the file
-        result = process_single_timestep(filepath, timestep)
-
-        # Extract and process results
-        electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a = result
-
-        # Calculate total electrons for each detector
-        total_electrons_for_detectors = np.sum(electrons_per_detector_per_step, axis=(0, 1))
-        print("Total electrons per detector:", total_electrons_for_detectors)
-
-        return result
-
-    except Exception as e:
-        print(f"Error processing file: {str(e)}")
-        raise
-
-
-if __name__ == "__main__":
-    results = main()
\ No newline at end of file
diff --git a/apps/showcases/FreeSurface/raytracerVDB-120.py b/apps/showcases/FreeSurface/raytracerVDB-120.py
deleted file mode 100644
index 29ec1af8361d97f52b7d5e35a621cccc4ad2ff42..0000000000000000000000000000000000000000
--- a/apps/showcases/FreeSurface/raytracerVDB-120.py
+++ /dev/null
@@ -1,1737 +0,0 @@
-import pyopenvdb as vdb
-import os
-from math import floor
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.colors as colors
-from matplotlib.animation import FuncAnimation
-from typing import Tuple, List
-import scipy.ndimage as ndimage
-from scipy.ndimage import gaussian_filter
-from scipy.spatial.transform import Rotation as Ro
-from scipy.spatial.transform import Slerp
-from scipy.interpolate import PchipInterpolator
-from sympy.printing.tree import print_node
-
-
-def runRayTracer(x_num, y_num, z_num, fill_levels, current_timestep):
-
-    print("current_timestep", current_timestep)
-    # fill_levels = np.clip(fill_levels, 0, 1)
-    fill_levels = fill_levels.astype(np.float64)
-
-    # Define the tolerance
-    tolerance = 1e-8
-
-    # Check if any value in fill_levels is outside the [0, 1] range
-    out_of_range = np.logical_or(fill_levels < -tolerance, fill_levels > 1 + tolerance)
-    if np.any(out_of_range):
-        out_of_range_values = fill_levels[out_of_range]
-        out_of_range_indices = np.where(out_of_range)
-
-        for idx, value in zip(zip(*out_of_range_indices), out_of_range_values):
-            print(f"Index: {idx}, Value: {value}")
-
-        # raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    # Check for fill levels within the range [0, 1]
-    in_range = np.logical_and(fill_levels > 0, fill_levels < 1)
-    in_range_values = fill_levels[in_range]
-    in_range_indices = np.where(in_range)
-
-    # print("Fill level values within range (0 to 1):")
-    # for idx, value in zip(zip(*in_range_indices), in_range_values):
-    # if idx == (np.int64(309),):
-    # print(f"Index: {idx}, Value: {value}")
-
-    # Constants
-    pi: float = np.pi  # Mathematical constant π
-    e: float = 1.60217662e-19  # Elementary charge in Coulombs
-    beam_current: float = 3e-3  # Beam current in Amperes
-    dwell_time: float = 0.32e-6  # Dwell time in seconds
-    A: float = 21.5  # Approximation for Ti6Al4V
-    # Ti6Al4V = Ti6Al4V.create_Ti6Al4V(T)
-    # A: float = Ti6Al4V.atomic_number
-    # print('Ti6Al4V.elements: ', Ti6Al4V.elements)
-    # print('Ti6Al4V.composition: ', Ti6Al4V.composition)
-    # print('A: ', A)
-    dx: float = 3e-6  # Cell size in meters
-    dy: float = dx  # Cell size in meters
-    dz: float = dx  # Cell size in meters
-    threshold: float = 0  # Beam profile threshold
-    # The voltage at detector d can be computed using the transimpedance T (in units of V /A ) of a virtual amplifier in the context of the model
-    transimpedance: float = 1e5  # Transimpedance in Volts per Ampere
-    eV_threshold = 50  # Threshold energy in eV
-    threshold_energy = eV_threshold * e  # Threshold energy in joules
-    # Define the work distance (height of the electron beam gun)
-    # w_d = 12e-3  # Work distance in meters (272 mm)
-
-    # Domain setup (Section 2.1.1)
-    # Define the 3D domain for the simulation
-    x_min, x_max = -0.5 * x_num * dx, 0.5 * x_num * dx  # -10 mm to 10 mm
-    # print('x_min, x_max:', x_min, x_max)
-    y_min, y_max = -0.5 * y_num * dy, 0.5 * y_num * dy  # -10 mm to 10 mm
-    # print('y_min, y_max:', y_min, y_max)
-    z_min, z_max = 0, z_num * dz
-    # print('z_min, z_max:', z_min, z_max)
-    # x_num = int((x_max - x_min) / dx) + 1
-    # y_num = int((y_max - y_min) / dy) + 1
-    # z_num = int((z_max - z_min) / dz) + 1
-    # print(f"Domain dimensions: x={x_num}, y={y_num}, z={z_num}")
-    # domain = np.zeros((z_num, y_num, x_num))  # (z, y , x)
-    # print('domain shape: ', np.shape(domain))
-    # domain[0:2, :, :] = 1  # Solid cells
-    # domain[2, :, :] = 0.5  # Interface layer
-    fill_levels_reshaped = np.reshape(fill_levels, (z_num, y_num, x_num))
-    # fill_levels_reshaped = fill_levels
-    # print('fill_levels_reshaped:', fill_levels_reshaped)
-    out_of_range_fill_levels = np.logical_or(fill_levels_reshaped < -tolerance, fill_levels_reshaped > 1 + tolerance)
-    if np.any(out_of_range_fill_levels):
-        out_of_range_fill_level_values = fill_levels_reshaped[out_of_range_fill_levels]
-        out_of_range_fill_level_indices = np.where(out_of_range_fill_levels)
-
-        for idx, value in zip(zip(*out_of_range_fill_level_indices), out_of_range_fill_level_values):
-            print(f"Index: {idx}, Value: {value}")
-        raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    sigma = 0.0
-    # Different sigma for each dimension
-    # sigma = [0.0, 1.0, 0.5]  # [z, y, x]
-    fill_levels_smoothed = ndimage.gaussian_filter(fill_levels_reshaped, sigma=sigma, mode='nearest', radius=1)
-
-    fill_levels_smoothed = np.clip(
-        fill_levels_smoothed,
-        a_min=np.float64(0.0),
-        a_max=np.float64(1.0)
-    )
-
-    # Check if values are still in bounds
-    if not (np.all(fill_levels_smoothed >= 0) and np.all(fill_levels_smoothed <= 1)):
-        raise ValueError("Smoothed fill levels out of bounds!")
-
-
-    def interpolate_normals(_normal_below, _normal_above, t):
-        """Interpolate between two normals using SLERP"""
-        # Ensure normals are normalized
-        _normal_below = normalize_vector(_normal_below)
-        _normal_above = normalize_vector(_normal_above)
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # If normals are very close, use linear interpolation
-        if dot_product > np.cos(np.deg2rad(1)):
-            _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-        # If normals are nearly opposite, rotate around arbitrary axis
-        elif dot_product < -0.9999:
-            # Create perpendicular vector for rotation
-            perp = np.array([1.0, 0.0, 0.0]) if abs(_normal_below[1]) < 0.9 else np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            # Rotate 180 degrees around perp
-            _normal_interp = _normal_below * np.cos(np.pi * t) + perp * np.sin(np.pi * t)
-        # Otherwise use spherical interpolation
-        else:
-            theta = np.arccos(dot_product)
-            sin_theta = np.sin(theta)
-            _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                             (np.sin(t*theta) / sin_theta) * _normal_above
-
-        return normalize_vector(_normal_interp)
-
-
-    def interpolate_fill_level_and_normal(_fill_level, surface_normals, _y, _x):
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-        # Find the indices where the fill level crosses 0.5 in either direction
-        indices_above = np.where((_fill_level[:, _y, _x][:-1] > 0.5) & (_fill_level[:, _y, _x][1:] <= 0.5))[0]
-        print('indices_above:', indices_above)
-        indices_below = np.where((_fill_level[:, _y, _x][:-1] <= 0.5) & (_fill_level[:, _y, _x][1:] > 0.5))[0]
-        print('indices_below:', indices_below)
-
-        # if len(indices_above) == 0 and len(indices_below) == 0:
-        # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-        # return None, None, None, None, None, None, None
-
-        if len(indices_above) > 0:
-            print("len(indices_above) > 0")
-            # Fill level crosses 0.5 from above to below
-            z_index = indices_above[0]
-            print('z_index:', z_index)
-            _fill_level_above = _fill_level[z_index, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-            _fill_level_below = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-        elif len(indices_below) > 0:
-            print("len(indices_below) > 0")
-            # Fill level crosses 0.5 from below to above
-            z_index = indices_below[0]
-            print('z_index:', z_index)
-            _fill_level_below = _fill_level[z_index, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-            _fill_level_above = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-        else:
-            # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-            print("No interface cells found")
-            return None, None, None, None, None, None, None
-
-        # Interpolate the z-coordinate where the fill level is 0.5
-        denominator = _fill_level_below - _fill_level_above
-        if abs(denominator) < 1e-6:
-            # Handle the case where the denominator is very close to zero
-            _z_interp = z_index + 0.5
-        else:
-            _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-        # _z_interp = z_index + (0.5 - bilinear_interpolate(_x, _y, z_index, _fill_level)) / (bilinear_interpolate(_x, _y, z_index+1, _fill_level) - bilinear_interpolate(_x, _y, z_index, _fill_level))
-        print('_z_interp:', _z_interp)
-
-        _z_height = (_z_interp + 0.5) * dz
-        print('_z_height:', _z_height)
-
-        # Interpolate the surface normal at the interpolated z-coordinate
-        _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-        print('_normal_below:', _normal_below)
-        _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-        print('_normal_above:', _normal_above)
-
-        t = _z_interp - z_index
-
-        # Use spherical interpolation (slerp) for more accurate normal interpolation
-        dot_product = np.dot(_normal_below, _normal_above).astype(np.float64)
-        if dot_product > np.cos(np.deg2rad(1)):  # Threshold for using linear interpolation
-            # If normals are very close, use linear interpolation
-            _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-        else:
-            # Use spherical interpolation
-            theta = np.arccos(dot_product)
-            sin_theta = np.sin(theta)
-            _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + (np.sin(t*theta) / sin_theta) * _normal_above
-            # _normal_interp = quaternion_interpolate(_normal_below, _normal_above, t)
-
-        _normal_interp = normalize_vector(_normal_interp)
-        print('_normal_interp:', _normal_interp)
-
-        return _z_interp, _z_height, _fill_level_below, _fill_level_above, _normal_interp, _normal_below, _normal_above
-
-
-    def interpolate_fill_level_and_normal1(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom (reverse order)
-        for z in range(len(column)-2, -1, -1):  # Start from second-to-last index
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Check if we cross 0.5 threshold
-            if (fill_current - 0.5) * (fill_next - 0.5) <= 0:
-                # Found interface
-                z_index = z
-                _fill_level_above = fill_current
-                _fill_level_below = fill_next
-
-                print(f"Found interface at z={z_index}")
-                print(f"fill_level_above: {_fill_level_above}")
-                print(f"fill_level_below: {_fill_level_below}")
-
-                # Interpolate z position
-                denominator = _fill_level_below - _fill_level_above
-                if abs(denominator) < 1e-6:
-                    _z_interp = z_index + 0.5
-                else:
-                    _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normals
-                t = _z_interp - z_index
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, _fill_level_below, _fill_level_above, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal2(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Look for significant transition from liquid (>0.5) to gas (<0.2)
-            if fill_current > 0.5 and fill_next < 0.2:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_above: {fill_next}")  # gas phase
-                print(f"fill_level_below: {fill_current}")  # liquid phase
-
-                # Interpolate z position
-                denominator = fill_next - fill_current
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - fill_current) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, fill_next, fill_current, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal3(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Look for significant liquid-liquid or liquid-gas interface
-            if current_fill > 0.5 and next_fill < 0.5:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_below: {current_fill}")  # higher fill level
-                print(f"fill_level_above: {next_fill}")     # lower fill level
-
-                # Interpolate z position
-                denominator = next_fill - current_fill
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - current_fill) / denominator)
-
-                print('_z_interp', _z_interp)
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, next_fill, current_fill, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-    def interpolate_fill_level_and_normal4(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Look for transition from solid (=1) to liquid (<1)
-            if current_fill == 1.0 and next_fill < 1.0:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_below: {next_fill}")  # liquid phase
-                print(f"fill_level_above: {current_fill}")  # solid phase
-
-                # Interpolate z position
-                denominator = next_fill - current_fill
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - current_fill) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z + 1, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, next_fill, current_fill, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal5(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Look for liquid-gas interface (current > 0, next = 0)
-            if current_fill > 0 and next_fill == 0:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_below: {current_fill}")  # liquid phase
-                print(f"fill_level_above: {next_fill}")     # gas phase
-
-                # Interface is at z since this is the last liquid cell
-                denominator = next_fill - current_fill
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - current_fill) / denominator)
-
-                print('_z_interp', _z_interp)
-                _z_height = (_z_interp + 0.5) * dz
-                print("_z_height:", _z_height)
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                print("t:", t)
-
-                # Use default normal if either normal is invalid
-                if np.any(np.isnan(_normal_below)) or np.any(np.isnan(_normal_above)):
-                    _normal_interp = np.array([0.0, 0.0, 1.0])
-                else:
-                    # Interpolate normal at interface
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                # _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                return _z_interp, _z_height, next_fill, current_fill, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel(_fill_levels: np.ndarray) -> np.ndarray:
-        """
-        Compute surface normals using Sobel filter with enhanced numerical precision.
-        """
-        # Convert input to high precision at the start
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        # Input validation
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        # Define tolerances for different checks
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Validate domain bounds with high precision
-        '''invalid_elements = np.logical_or(_fill_levels < -TOLERANCE['fill_level_bounds'],
-                                         _fill_levels > 1 + TOLERANCE['fill_level_bounds'])
-        if np.any(invalid_elements):
-            invalid_indices = np.where(invalid_elements)
-            for _z, _y, _x in zip(*invalid_indices):
-                _value = _fill_levels[_z, _y, _x]
-                print(f"Warning: Invalid value {_value} at [z={_z}, y={_y}, x={_x}]")'''
-
-        # Compute gradients using Sobel filter with explicit high precision
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)  # multiply by -1 to invert the surface normals to point in +z direction
-
-        # Stack gradients into normal vector array with maintained precision
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm with high precision
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-        nonzero_mask = norm > TOLERANCE['norm']
-        valid_mask = interface_mask & nonzero_mask
-
-        # Normalize vectors only where valid
-        # _n_S[valid_mask] /= norm[valid_mask][..., np.newaxis]
-        # _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-        _n_S_normalized[valid_mask] = _n_S[valid_mask] / norm[valid_mask, np.newaxis]
-
-        # Verify normalization
-        if not np.allclose(np.linalg.norm(_n_S_normalized[valid_mask], axis=-1),1.0, atol=TOLERANCE['normalization']):
-            raise ValueError("Normalization failed for some vectors")
-
-        # return _n_S
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel1(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        # filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        filled_mask = _fill_levels >= 1
-        # empty_mask = _fill_levels <= TOLERANCE['zero']
-        empty_mask = _fill_levels <= 0
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Default normal for sharp transitions (pointing up)
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0  # Default z-direction normal
-
-        # Expand valid_mask to match _n_S shape
-        valid_mask_expanded = valid_mask[..., np.newaxis]
-
-        # Where we have valid gradients, use them
-        gradient_mask = (norm > TOLERANCE['norm'])[..., np.newaxis]
-        mask = valid_mask_expanded & gradient_mask
-
-        # Normalize vectors where mask is True
-        norm_expanded = norm[..., np.newaxis]
-        _n_S_normalized = np.where(mask, _n_S / np.where(norm_expanded > 0, norm_expanded, 1.0), _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel2(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        empty_mask = _fill_levels <= TOLERANCE['zero']
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Initialize output array with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Create mask for valid gradients
-        gradient_mask = norm > TOLERANCE['norm']
-
-        # Combine masks and reshape for broadcasting
-        mask = valid_mask & gradient_mask
-
-        # Normalize only where mask is True
-        for i in range(3):  # For each component (x, y, z)
-            _n_S_normalized[..., i] = np.where(mask,
-                                               _n_S[..., i] / np.where(norm > 0, norm, 1.0),
-                                               np.nan)
-
-        return _n_S_normalized
-
-
-    def interpolate_normals_new(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """Interpolate between two normals using SLERP"""
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-
-        # Ensure normalized vectors
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Thresholds for special cases
-        PARALLEL_THRESHOLD = 0.9999
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            return normalize_vector((1.0 - t) * _normal_below + t * _normal_above)
-
-        # Case 2: Nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            angle = np.pi * t
-            return normalize_vector(_normal_below * np.cos(angle) + perp * np.sin(angle))
-
-        # Case 3: Standard SLERP
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        return normalize_vector(scale0 * _normal_below + scale1 * _normal_above)
-
-
-    def interpolate_fill_level_and_normal_new(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-
-                # Check for interface crossing (VOF method)
-                crosses_interface = (
-                    (current_fill >= (INTERFACE_THRESHOLD - TOLERANCE) and
-                     next_fill <= (INTERFACE_THRESHOLD + TOLERANCE))
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Handle numerical precision near 0.5
-                        if abs(current_fill - INTERFACE_THRESHOLD) < TOLERANCE:
-                            t_interface = 0.0
-                        elif abs(next_fill - INTERFACE_THRESHOLD) < TOLERANCE:
-                            t_interface = 1.0
-                        else:
-                            # Calculate interpolation parameter
-                            t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-
-                        # Ensure t is in valid range
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels for context
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals_new(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel_zeros(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-    def compute_surface_normals_sobel_nans(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter with NaN initialization"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-    # Compute surface normals (Section 2.1.1)
-    n_S = compute_surface_normals_sobel_nans(fill_levels_smoothed)
-    # print('n_S: ', np.shape(n_S))
-
-    # Beam profile (Section 2.1)
-    # Define the Gaussian beam profile
-    sigma: float = 500e-6 / (4 * dx)  # Beam profile sigma
-    x = np.arange(-np.floor(min(x_num//4, 2*sigma)), 1+np.floor(min((x_num//4), 2*sigma)))
-    # print('x:', x)
-    y = np.arange(-floor(min(y_num//4, 2*sigma)), 1+floor(min((y_num//4), 2*sigma)))
-    # print('y:', y)
-    X, Y = np.meshgrid(x, y)
-    beam_profile = np.exp(-(X ** 2 + Y ** 2) / (2 * sigma ** 2))
-    beam_profile /= np.sum(beam_profile)  # Normalize beam profile
-    print('beam_profile shape: ', np.shape(beam_profile))
-
-    beam_size = np.shape(beam_profile)[0] * np.shape(beam_profile)[1]
-    # print('Beam size:', beam_size)
-
-    # Detector setup (Section 2.1.1)
-    # Define positions of the detectors
-    detector_height = 4 * z_max  # Detector height in meters (272 mm)
-    detector_spacing_x = 1.5 * dx * x_num  # Detector spacing in meters (127 mm)
-    detector_spacing_y = detector_spacing_x  # 1.5 * dy * y_num  # Detector spacing in meters (127 mm)
-    detector_diameter = 4 * dx  # Detector diameter in meters (50 mm)
-
-    # Define detector positions relative to the origin
-    detector_positions = np.array([
-        [0.5 * detector_spacing_x, 0, detector_height],
-        [-0.5 * detector_spacing_x, 0, detector_height],
-        [0, 0.5 * detector_spacing_y, detector_height],
-        [0, -0.5 * detector_spacing_y, detector_height]
-    ], dtype=np.float64)
-    # print('detector_positions:', detector_positions)
-
-
-    # Define detector vertices for solid angle calculation
-    def get_detector_vertices(detector_position: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
-        # Calculate the side length of the detector
-        side_length : float = np.sqrt(3) * (detector_diameter / 2)
-        height : float = np.sqrt(3) * (side_length / 2)
-
-        # Determine the orientation based on the detector position
-        if np.allclose(detector_position[:2], [0, 0]):
-            raise ValueError("Detector position cannot be at the origin (0, 0, 0)")
-        elif detector_position[0] > 0:  # Right detector
-            _v1 = detector_position + np.array([2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-height / 3, side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-height / 3, -side_length / 2, 0], dtype=np.float64)
-        elif detector_position[0] < 0:  # Left detector
-            _v1 = detector_position + np.array([-2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([height / 3, -side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([height / 3, side_length / 2, 0], dtype=np.float64)
-        elif detector_position[1] > 0:  # Back detector
-            _v1 = detector_position + np.array([0, 2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-side_length / 2, -height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([side_length / 2, -height / 3, 0], dtype=np.float64)
-        elif detector_position[1] < 0:  # Front detector
-            _v1 = detector_position + np.array([0, -2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([side_length / 2, height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-side_length / 2, height / 3, 0], dtype=np.float64)
-        else:
-            raise ValueError("Invalid detector position. Must be in one of the four expected positions.")
-
-        return _v1, _v2, _v3
-
-
-    # Compute solid angle using Equation A1 (Appendix A)
-    def compute_solid_angle(vector1: np.ndarray, vector2: np.ndarray, vector3: np.ndarray) -> float:
-        # Input validation
-        if not isinstance(vector1, np.ndarray) or not isinstance(vector2, np.ndarray) or not isinstance(vector3, np.ndarray):
-            raise ValueError("Inputs must be numpy arrays")
-        if vector1.shape != (3,) or vector2.shape != (3,) or vector3.shape != (3,):
-            raise ValueError("Inputs must be 3D vectors")
-        if not np.isfinite(vector1).all() or not np.isfinite(vector2).all() or not np.isfinite(vector3).all():
-            raise ValueError("Inputs must be finite")
-
-        # Convert to high precision once
-        vectors = [vec.astype(np.float64) for vec in (vector1, vector2, vector3)]
-        cross_product = np.cross(vectors[1], vectors[2])
-        # Check if the cross product is nearly zero
-        if np.linalg.norm(cross_product) < 1e-16:
-            raise ValueError("Vectors are nearly coplanar")
-
-        # Compute solid angle
-        try:
-            # Compute the numerator of the solid angle formula
-            numerator: float = np.dot(vectors[0], cross_product)
-            # Check if the numerator is nearly zero
-            if np.abs(numerator) < 1e-16:
-                raise ValueError("Vectors are nearly parallel")
-
-            norms = [np.linalg.norm(v) for v in vectors]
-
-            # Compute the denominator of the solid angle formula
-            denominator = (
-                    norms[0] * norms[1] * norms[2] +
-                    np.dot(vectors[0], vectors[1]) * norms[2] +
-                    np.dot(vectors[0], vectors[2]) * norms[1] +
-                    np.dot(vectors[1], vectors[2]) * norms[0]
-            )
-
-            # Compute the solid angle in radians
-            solid_angle = 2 * np.arctan2(numerator, denominator)
-            # print("Solid angle:", solid_angle)
-            return solid_angle
-        except ZeroDivisionError:
-            raise ValueError("Error computing solid angle: division by zero")
-        except np.linalg.LinAlgError:
-            raise ValueError("Error computing solid angle: linear algebra error")
-
-
-    # BSE coefficient functions (Equations 11 and 10, Section 2.1.2)
-    def eta_0(_A: float) -> float:
-        """Equation 11 from the paper"""
-        _eta_0: float = -0.0254 + 0.016 * _A - 1.86e-4 * _A ** 2 + 8.3e-7 * _A ** 3
-        print('eta_0: ', _eta_0)
-        return _eta_0
-
-
-    # BSE Coefficient for single atomic targets, found to hold in the range of 10–100 keV
-    def eta(theta: float, _A: float) -> float:
-        """Equation 10 from the paper"""
-        B: float = 0.89
-        _eta: float = B * (eta_0(_A) / B) ** np.cos(theta)
-        print('eta: ', _eta)
-        return _eta
-
-
-    '''
-    # Compute the weighted average BSE coefficient for the alloy
-    def compute_weighted_eta(theta: float, alloy: Alloy) -> float:
-        """Equation 12 from the paper"""
-        weighted_eta: float = 0
-        for element, weight_fraction in zip(alloy.elements, alloy.composition):
-            # print('element: ', element)
-            # print('weight_fraction: ', weight_fraction)
-            _A: float = element.atomic_number
-            # print('A: ', _A)
-            eta_i: float = eta(theta, _A)
-            # print('eta_i: ', eta_i)
-            weighted_eta += weight_fraction * eta_i
-            # print('weighted_eta: ', weighted_eta)
-        return weighted_eta
-    '''
-
-
-    # Correction function C(theta) (Equation 14, Section 2.1.3)
-    def C(theta: float) -> float:
-        print("theta1:", theta)
-        """Equation 14 from the paper"""
-        theta = np.rad2deg(theta)  # Ensure angle is in degrees for C function
-        _C: float = 1.01 - 4.06 * theta + 1.36e-5 * theta ** 2 - 1.71e-6 * theta ** 3
-        print('C: ', _C)
-        # Check if C is negative and raise an exception
-        if _C < 0:
-            raise ValueError(f"C function returned a negative value for theta: {theta}. "
-                             f"Please check the input and the C function implementation.")
-        return _C
-
-
-    # Scattering function g_BSE (Equation 13, Section 2.1.3)
-    def g_BSE(_theta: float, _alpha: float, _beta: float) -> float:
-        print("_theta, _alpha, _beta:", _theta, _alpha, _beta)
-        """Equation 13 from the paper"""
-        # k: float = np.rad2deg(_theta) / 13  # Correct implementation as per the paper
-        # print("k: ", k)
-        diffusive_part: float = (eta_0(A) / pi) * np.cos(_alpha)
-        print(np.cos(_alpha))
-        print("diffusive_part:", diffusive_part)
-        # reflective_part: float = ((eta(_theta, A) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-        # if np.abs(reflective_part) < 1e-10:
-        # reflective_part = 0
-        # print("reflective_part:", reflective_part)
-
-        # Compute a weighted average BSE coefficient for the alloy
-        # reflective_part: float = ((compute_weighted_eta(_theta, Ti6Al4V) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-
-        # print('g_BSE: ', diffusive_part + reflective_part)
-        return diffusive_part  #+ reflective_part
-
-
-    # Calculate the beam direction vector p_E
-    def calculate_p_E(_beam_x, _beam_y):
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        # Calculate the vector from the beam gun to the beam location
-        beam_vector = np.array([_beam_x * dx, _beam_y * dy, -detector_height], dtype=np.float64)
-        return normalize_vector(beam_vector)
-
-    def calculate_p_E1(_beam_x, _beam_y, _height):
-        """Calculate normalized beam direction vector from gun to surface point
-
-        Args:
-            _beam_x: Beam x-position relative to center (in cells)
-            _beam_y: Beam y-position relative to center (in cells)
-            _height: Height difference between gun and surface point (m)
-        """
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        print("_height:", _height)
-
-        # Calculate vector components
-        x_component = _beam_x * dx  # Convert cells to meters
-        y_component = _beam_y * dy  # Convert cells to meters
-        z_component = _height       # Already in meters
-
-        # Create beam vector (from gun to surface point)
-        beam_vector = np.array([x_component, y_component, z_component], dtype=np.float64)
-
-        # Verify downward direction
-        if normalize_vector(beam_vector)[2] > 0:
-            raise ValueError("Beam vector pointing upward")
-
-        # Normalize and return
-        return normalize_vector(beam_vector)
-
-
-    # Calculate phi as the angle between p_E and the negative z-axis
-    def calculate_phi(_p_E) -> float:
-        z_axis = np.array([0.0, 0.0, -1.0], dtype=np.float64)
-
-        # Check if _p_E is approximately equal to the negative z-axis
-        '''if np.allclose(_p_E, z_axis, rtol=1e-8, atol=1e-8):
-            _phi_rad = 0.0
-        else:
-            # Calculate the angle between _p_E and the z-axis using arccos
-            _phi_rad = calculate_angle(_p_E, z_axis)'''
-        _phi_rad = calculate_angle(_p_E, z_axis)
-        print(f"calculate_phi({_p_E}, {_phi_rad}):")
-        return _phi_rad
-
-
-    def set_beam_movement(_x_center, _y_center, _x_offset, _y_offset, _movement_type):
-        if _movement_type == 'X':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            _beam_y_start = _y_center + _y_offset
-            _beam_y_end = _y_center + _y_offset + 1
-        elif _movement_type == 'Y':
-            _beam_x_start = _x_center + _x_offset
-            _beam_x_end = _x_center + _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        elif _movement_type == 'XY':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        else:
-            raise ValueError("movement_type must be 'X' or 'Y' or 'XY'")
-
-        return _beam_x_start, _beam_x_end, _beam_y_start, _beam_y_end
-
-
-    # Initialize arrays to store electron counts
-    electrons_per_detector_per_step = np.zeros((x_num, y_num, len(detector_positions)))
-    total_electrons_per_step = np.zeros((x_num, y_num))
-
-    # Additional arrays to capture detailed information
-    electrons_per_detector_per_beam_profile = np.zeros(
-        (beam_profile.shape[0], beam_profile.shape[1], len(detector_positions)))
-    total_electrons_per_beam_profile = np.zeros((beam_profile.shape[0], beam_profile.shape[1]))
-
-    # Calculate the center indices
-    x_center = x_num // 2
-    y_center = y_num // 2
-    # print('x_center, y_center:', x_center, y_center)
-
-    x_offset: int = 0
-    y_offset: int = 0
-    movement_type: str = 'Y'
-
-    # Beam X-Movement
-    # beam_x_start, beam_x_end = x_center - x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center + y_offset, y_center + y_offset + 1
-
-    # Beam Y-Movement
-    # beam_x_start, beam_x_end = x_center + x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center - y_offset, y_center + y_offset + 1
-
-    # For X or Y Movement
-    beam_x_start, beam_x_end, beam_y_start, beam_y_end = set_beam_movement(
-        x_center, y_center, x_offset, y_offset, movement_type)
-
-    # Determine the step size based on the direction of movement
-    step_x = 1 if beam_x_start <= beam_x_end else -1
-    step_y = 1 if beam_y_start <= beam_y_end else -1
-
-    # print(beam_x_start, beam_x_end, beam_y_start, beam_y_end, step_x, step_y)
-
-
-    z_heights = np.zeros(beam_size)
-    fill_level_b = np.zeros(beam_size)
-    fill_level_a = np.zeros(beam_size)
-    normals = np.zeros((beam_size, 3))
-    normals_b = np.zeros((beam_size, 3))
-    normals_a = np.zeros((beam_size, 3))
-    cell_counter = 0
-
-
-    def normalize_vector(vector: np.ndarray) -> np.ndarray:
-        """Normalize a 3D vector."""
-        vector = vector.astype(np.float64)  # Convert to float64
-        norm = np.linalg.norm(vector)
-        if norm < 1e-18:  # Avoid division by zero
-            return vector  # Return original if nearly zero
-        return vector / norm  # Normalize
-
-
-    def calculate_angle(_v1: np.ndarray, _v2: np.ndarray) -> float:
-        """Calculate the angle between two normalized vectors."""
-        _v1 = normalize_vector(_v1)
-        _v2 = normalize_vector(_v2)
-
-        dot_product = np.clip(np.dot(_v1, _v2), -1.0, 1.0)
-
-        return np.arccos(dot_product)  # Return angle in radians
-
-    # Main simulation loop to iterate over the simulation domain
-    for beam_x in range(beam_x_start, beam_x_end, step_x):
-        for beam_y in range(beam_y_start, beam_y_end, step_y):
-            beam_loc = (beam_x, beam_y)
-            print(f"Beam center is at domain location: {beam_loc}")
-
-            # Calculate the beam direction vector p_E
-            # >> p_E = calculate_p_E(beam_x - x_center, beam_y - y_center)
-            # >> print('p_E: ', p_E)
-
-            # Calculate phi as the angle between p_E and the negative z-axis
-            # phi_rad = np.arctan2(np.sqrt(dx**2 + dy**2), w_d)
-            # >> phi_rad = calculate_phi(p_E)
-            # print(f"phi_rad: {phi_rad}")
-
-            # Reset counters for this beam location
-            total_electrons: int = 0
-            electrons_per_detector = np.zeros(len(detector_positions))
-
-            # Iterate over the beam profile
-            for profile_x in range(beam_profile.shape[0]):
-            # for profile_x in [beam_profile.shape[0]//2 + 0]:
-                for profile_y in range(beam_profile.shape[1]):
-                # for profile_y in [beam_profile.shape[1]//2]:
-                    print(f"Beam profile shape ({profile_x}, {profile_y})")
-                    domain_x = beam_loc[0] - beam_profile.shape[0] // 2 + profile_x
-                    domain_y = beam_loc[1] - beam_profile.shape[1] // 2 + profile_y
-                    print(f"Domain ({domain_x}, {domain_y})")
-
-                    # p_E = calculate_p_E(domain_x - x_center, domain_y - y_center)
-                    # print('p_E: ', p_E)
-                    # phi_rad = calculate_phi(p_E)
-
-                    if (0 <= domain_x < x_num) and (0 <= domain_y < y_num):
-                        if beam_profile[profile_x, profile_y] > threshold:
-                            print('finding interface_z_indices')
-                            # print('interface_z_indices:', interface_z)
-                            z_interp, z_height, fill_level_below, fill_level_above, normal_interp, normals_below, normals_above \
-                                = interpolate_fill_level_and_normal_new(fill_levels_smoothed, n_S, domain_y, domain_x)
-                            if z_interp is not None:
-                                z_heights[cell_counter] = z_height
-                                fill_level_b[cell_counter] = fill_level_below
-                                fill_level_a[cell_counter] = fill_level_above
-                                normals[cell_counter] = normal_interp
-                                normals_b[cell_counter] = normals_below
-                                normals_a[cell_counter] = normals_above
-                                cell_counter += 1
-                                n_S_local = normal_interp
-                                print('n_S_local:', n_S_local)
-                                if not np.allclose(np.linalg.norm(n_S_local), 1.0, atol=1e-5):
-                                    raise ValueError(f"n_S_local is not normalized, {np.linalg.norm(n_S_local)}")
-
-                                p_E = calculate_p_E1(domain_x - x_center, domain_y - y_center, -(detector_height-z_height))
-                                print('p_E: ', p_E)
-
-                                theta_local = calculate_angle(-p_E, n_S_local)
-                                print('theta_local:', theta_local)
-                                # if theta_local < 0.0 or theta_local > 0.011:
-                                # raise ValueError(f"theta_local value should be 0.0, found {theta_local}")
-                                # theta_local: 0.016
-
-                                scattering_point = np.array([
-                                    (domain_x - beam_x) * dx,
-                                    (domain_y - beam_y) * dy,
-                                    z_height
-                                ], dtype=np.float64)
-                                print(f'scattering_point ({domain_x}, {domain_y}, {z_interp}):', scattering_point)
-
-                                for det_idx, det_pos in enumerate(detector_positions):
-                                    # print(f"Detector {det_idx + 1} position: {det_pos}")
-                                    det_idx: int
-                                    # det_pos: np.ndarray[float]
-                                    print(f'det_pos[{det_idx + 1}]: {det_pos}')
-
-                                    # Calculate the vector from the scattering point to the detector
-                                    det_vec = det_pos - scattering_point
-                                    det_vec = normalize_vector(det_vec)
-                                    print(f'det_vec[{det_idx + 1}]: {det_vec}')
-                                    if not np.allclose(np.linalg.norm(det_vec), 1.0):
-                                        raise ValueError("det_vec is not normalized.")
-
-                                    # alpha = np.arccos(np.clip(np.dot(det_vec, n_S_local), -1.0, 1.0))
-                                    alpha = calculate_angle(det_vec, n_S_local)
-                                    print(f'alpha: {alpha}')
-
-                                    if alpha > np.pi / 2:
-                                        continue
-
-                                    p_E_reflected = p_E - 2.0 * np.dot(p_E, n_S_local) * n_S_local
-                                    # p_E_reflected = np.where(np.abs(p_E_reflected) < 1e-8, 0.0, p_E_reflected)
-                                    p_E_reflected = normalize_vector(p_E_reflected)
-                                    print(f'p_E_reflected: {p_E_reflected}')
-
-                                    # beta = np.arccos(np.clip(np.dot(p_E_reflected, det_vec), -1.0, 1.0))
-                                    beta = calculate_angle(p_E_reflected, det_vec)
-                                    print(f'beta: {beta}')
-
-                                    # Apply energy threshold
-                                    # electron_energy = N_0 * e * g_bse_value
-                                    # print('electron_energy: ', electron_energy)
-
-                                    # Calculate the initial energy of the primary electrons (E_0)
-                                    V_acc = 60e3  # Accelerating voltage in Volts
-                                    E_0 = V_acc * e  # Initial energy of primary electrons in joules
-
-                                    # Calculate the energy of the backscatter electrons
-                                    E_BSE = E_0 * eta(theta_local, A)
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # E_BSE = E_0 * compute_weighted_eta(theta_local, Ti6Al4V)
-                                    # print('E_BSE: ', E_BSE)
-
-                                    # Bifurcation logic for PE and SE
-                                    # if E_BSE > threshold_energy:
-                                    # Backscatter electrons (BSE)
-                                    # Compute scattering function g_bse_value
-                                    g_bse_value = g_BSE(theta_local, alpha, beta)
-                                    print(f'g_bse_value: {g_bse_value}')
-
-                                    # Ensure g_bse_value is non-negative
-                                    if g_bse_value <= 0:
-                                        raise ValueError(f"g_bse_value is {g_bse_value} (zero negative), check calculations.")
-
-                                    # Compute solid angle (Equation A1)
-                                    v1, v2, v3 = get_detector_vertices(det_pos)
-                                    normal = np.cross(v2 - v1, v3 - v1)
-                                    normal = normalize_vector(normal)
-
-                                    dOmega = compute_solid_angle(v1 - scattering_point,
-                                                                 v2 - scattering_point,
-                                                                 v3 - scattering_point)
-                                    print(f"Detector {det_idx + 1} solid angle: {dOmega}")
-
-                                    # Compute number of electrons collected by the detector (Equation A2)
-                                    # The beam ray stays on each grid point ij for the dwell time t_d and distributes N_0 PE into it
-                                    N_0 = beam_current * dwell_time / e  # Equation 3, Section 2.1.1
-                                    print("N_0:", N_0)
-
-                                    # There are N1 electrons in the chamber after the 1st scattering event
-                                    N_1 = N_0 * eta(theta_local, A)  # Equation 4, Section 2.1.1
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # N_1 = N_0 * compute_weighted_eta(theta_local, Ti6Al4V)  # Equation 4, Section 2.1.1
-                                    print("N_1:", N_1)
-
-                                    # The number of electrons N_1_d collected by a general detector d
-                                    N_1_d = N_1 * np.sum(g_bse_value * dOmega)  # Equation 5, Section 2.1.1
-
-                                    # Accumulate electrons per detector for this beam profile point
-                                    electrons_per_detector_per_beam_profile[profile_x, profile_y, det_idx] += N_1_d
-
-                                    # Accumulate electrons per detector for this domain point
-                                    electrons_per_detector_per_step[beam_x, beam_y, det_idx] += N_1_d
-
-                                    # Add to total electrons for this beam profile point
-                                    total_electrons_per_beam_profile[profile_x, profile_y] += N_1_d
-
-                                    # Add to total electrons for this domain point
-                                    total_electrons_per_step[beam_x, beam_y] += N_1_d
-
-                                    # Accumulate electrons per detector
-                                    electrons_per_detector[det_idx] += N_1_d
-
-                                    # Add to total electrons
-                                    total_electrons += N_1_d
-
-                                    # print(f"After addition: electrons_per_detector[{det_idx + 1}]: {electrons_per_detector[det_idx]}")
-                            else:
-                                print(f"No interface found at ({domain_x}, {domain_y})")
-
-                    # Print detailed information for this beam profile point
-                    # print(f"Electrons per detector at this beam profile point ({profile_x}, {profile_y}): {electrons_per_detector_per_beam_profile[profile_x, profile_y]}")
-                    # print(f"Total electrons at this beam profile point ({profile_x}, {profile_y}): {total_electrons_per_beam_profile[profile_x, profile_y]}")
-
-            # Print detailed information for this domain point after iterating over the beam profile
-            # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-            # print(f"Total electrons at this domain point ({beam_x}, {beam_y}): {total_electrons_per_step[beam_x, beam_y]}")
-
-            # print("Shape of electrons_per_detector_per_step:", electrons_per_detector_per_step.shape)
-            # print("Number of lines:", len(lines))
-
-    # Compute final results
-    total_electrons_all_steps = np.sum(total_electrons_per_step)
-    total_electrons_per_detector = np.sum(electrons_per_detector_per_step, axis=(0, 1))
-
-    # Print final results
-    # print(f"Total electrons hitting all detectors: {total_electrons_all_steps}")
-    # for i, electrons in enumerate(total_electrons_per_detector):
-    # print(f"Total electrons hitting detector {i + 1}: {electrons}")
-
-    # for beam_x in range(beam_x_start, beam_x_end, step_x):
-    # for beam_y in range(beam_y_start, beam_y_end, step_y):
-    # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-    # print('end simulation')
-    # print(electrons_per_detector_per_step)
-
-    # print("total_electrons:", total_electrons, "at timestep", current_timestep)
-    # print("electrons_per_det:", electrons_per_det, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc:", electrons_per_beam_loc, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc_per_det:", electrons_per_beam_loc_per_det, "at timestep", current_timestep)
-    return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, normals, normals_b, normals_a
-
-def read_fill_levels(filepath):
-    """Read only the FillingFrac grid from VDB file"""
-    try:
-        # Try to read just the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        print(f"Grid dimensions: {dims}")
-
-        # Create array
-        fill_array = np.zeros(dims, dtype=np.float64)
-
-        # Copy data
-        grid.copyToArray(fill_array)
-
-        # Transpose to ray tracer's expected order (z, y, x)
-        fill_array = np.transpose(fill_array, (2, 1, 0))
-
-        # Create standardized array with 256 z cells
-        standardized_array = np.zeros((256, dims[1], dims[0]), dtype=np.float64)
-
-        # Copy data from original array to standardized array
-        standardized_array[:fill_array.shape[0], :, :] = fill_array
-
-        # Apply Gaussian smoothing
-        sigma = 1.0
-        standardized_array_smoothed = ndimage.gaussian_filter(standardized_array, sigma=sigma, mode='nearest', radius=1)
-
-        # Print values
-        '''x = 157
-        y = 196
-        print(f"\nFill levels at ({x}, {y}):")
-        print("z_index | fill_level")
-        print("-" * 20)
-
-        # Get fill levels
-        fill_levels = standardized_array_smoothed[:, y, x]
-        valid_levels = fill_levels[(fill_levels > 0) & (fill_levels < 1)]
-
-        if len(valid_levels) > 0:
-            for z, val in enumerate(fill_levels):
-                if 0 <= val <= 1:
-                    print(f"{z:7d} | {val:.6f}")
-        else:
-            print("No fill level found between 0 and 1")'''
-
-        return standardized_array_smoothed
-
-    except Exception as e:
-        print(f"Error reading {filepath}")
-        print(f"Error details: {str(e)}")
-        try:
-            metadata = vdb.readMetadata(filepath)
-            print("\nFile metadata:")
-            for key, value in metadata.items():
-                print(f"{key}: {value}")
-        except Exception as meta_e:
-            print(f"Error reading metadata: {str(meta_e)}")
-        return None
-
-def process_single_timestep(filepath, timestep):
-    """Process a single VDB file and run ray tracer"""
-    try:
-        # Read the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        x_num, y_num = dims[0], dims[1]
-        z_num = 256  # dims[2] # Fixed z dimension
-        print(f"Domain dimensions: {x_num} x {y_num} x {z_num}")
-
-        # Read fill levels
-        fill_levels = read_fill_levels(filepath)
-        if fill_levels is None:
-            raise ValueError("Could not read fill levels from VDB file")
-
-        '''if fill_levels is not None:
-            print("\nSuccessfully read fill levels")
-            print(f"Array shape: {np.shape(fill_levels)}")
-
-            try:
-                # Convert to numpy array if not already
-                fill_array = np.asarray(fill_levels)
-
-                # Get non-zero elements
-                non_zero = fill_array[fill_array > 0]
-
-                # Get interface cells (0 < fill_level < 1)
-                interface_cells = fill_array[(fill_array > 0) & (fill_array < 1)]
-
-                # Print statistics
-                if len(non_zero) > 0:
-                    print("\nNon-zero cells statistics:")
-                    print(f"Number of non-zero cells: {len(non_zero)}")
-                    print(f"Min non-zero value: {np.min(non_zero):.6f}")
-                    print(f"Max value: {np.max(fill_array):.6f}")
-
-                if len(interface_cells) > 0:
-                    print("\nInterface cells statistics:")
-                    interface_percentage = (len(interface_cells) * 100.0) / len(non_zero)
-                    print(f"Number of interface cells: {len(interface_cells)} ({interface_percentage:.3f}%)")
-                    print(f"Min interface value: {np.min(interface_cells):.6f}")
-                    print(f"Max interface value: {np.max(interface_cells):.6f}")
-                    print(f"Mean interface value: {np.mean(interface_cells):.6f}")
-                else:
-                    print("\nNo interface cells found (no fill levels between 0 and 1)")
-
-            except Exception as e:
-                print(f"Error processing array: {str(e)}")'''
-
-        # Run ray tracer
-        print(f"\nProcessing timestep {timestep}")
-        electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a = runRayTracer(
-            x_num,
-            y_num,
-            z_num,
-            # fill_levels.flatten(),
-            fill_levels,
-            timestep
-        )
-
-        return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a
-
-    except Exception as e:
-        print(f"Error processing timestep {timestep}: {str(e)}")
-        raise
-
-def main():
-    """Main function to process VDB files and run ray tracer"""
-    # Directory containing VDB files
-    directory = "/local/ca00xebo/softwares/KiSSAM/Markl-plate"
-
-    # Get sorted list of VDB files
-    vdb_files = []
-    for f in os.listdir(directory):
-        if f.startswith('geometry-0000') and f.endswith('.vdb'):
-            try:
-                # Extract timestep number from filename
-                timestep = int(f.split('-')[1].split('.')[0])
-                vdb_files.append((timestep, f))
-            except (IndexError, ValueError) as e:
-                print(f"Skipping file {f}: {str(e)}")
-
-    # Sort by timestep
-    vdb_files.sort()  # sort based on timestep number
-
-    # Slice the first 120 files
-    vdb_files = vdb_files[:120]
-
-    total_timesteps = len(vdb_files)
-    print(f"Found {total_timesteps} VDB files to process")
-    electrons_across_timesteps = np.zeros((total_timesteps, 4))
-
-    # Process each timestep
-    _results = []
-    for timestep, vdb_file in enumerate(vdb_files):
-        filepath = os.path.join(directory, vdb_file[1])  # vdb_file[1] contains the filename
-        try:
-            print(f"\nProcessing file {vdb_file[1]} (timestep {timestep})")
-
-            # Process this timestep
-            _result = process_single_timestep(filepath, timestep)
-
-            # Debug electron counts
-            '''print("\nElectron count debug:")
-            print(f"Shape of electrons_per_detector_per_step: {_result[0].shape}")
-            print(f"Min value: {np.min(_result[0])}")
-            print(f"Max value: {np.max(_result[0])}")
-            print(f"Mean value: {np.mean(_result[0])}")'''
-
-            # Store electron counts
-            total_electrons_for_detectors = np.sum(_result[0], axis=(0, 1))
-            print(f"Total electrons before assignment: {total_electrons_for_detectors}")
-
-            electrons_across_timesteps[timestep] = total_electrons_for_detectors
-            print(f"Electrons for timestep {timestep}:")
-            print(f"Raw values: {electrons_across_timesteps[timestep]}")
-            # print(f"Sum: {np.sum(electrons_across_timesteps[timestep])}")
-            print("electrons_across_timesteps:", electrons_across_timesteps)
-
-            _results.append(_result)
-            print(f"Completed timestep {timestep}")
-
-        except Exception as e:
-            print(f"Error processing timestep {timestep}: {str(e)}")
-            raise
-
-    # Final debug output
-    '''print("\nFinal electrons_across_timesteps:")
-    print(f"Shape: {electrons_across_timesteps.shape}")
-    print(f"Min value: {np.min(electrons_across_timesteps)}")
-    print(f"Max value: {np.max(electrons_across_timesteps)}")
-    print(f"Mean value: {np.mean(electrons_across_timesteps)}")'''
-
-    # Plot results
-    plot_detector_results1(electrons_across_timesteps)
-
-    return _results
-
-def plot_detector_results(electrons_per_detector_time):
-    """Plot electron counts for each detector across timesteps"""
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot for each detector
-    for detector in range(4):
-        plt.plot(timesteps, electrons_per_detector_time[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-
-    # Add value annotations
-    for detector in range(4):
-        for t, value in zip(timesteps, electrons_per_detector_time[:, detector]):
-            plt.annotate(f'{value:.2e}',
-                         (t, value),
-                         textcoords="offset points",
-                         xytext=(0,10),
-                         ha='center',
-                         rotation=45)
-
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time(VDB-120).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create additional visualization - normalized values
-    plt.figure(figsize=(12, 8))
-    normalized_electrons = electrons_per_detector_time / np.max(electrons_per_detector_time)
-
-    for detector in range(4):
-        plt.plot(timesteps, normalized_electrons[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Normalized Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Normalized Electron Count")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time_normalized(VDB-120).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-def plot_detector_results1(electrons_per_detector_time):
-    """
-    Plot electron counts for detector pairs across timesteps
-
-    Args:
-        electrons_per_detector_time: Array of shape (num_timesteps, 4) containing
-                                   electron counts for each detector at each timestep
-                                   :param electrons_per_detector_time:
-    """
-    # Create figure for detectors 1 and 2
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot detectors 1 and 2
-    plt.plot(timesteps, electrons_per_detector_time[:, 0],
-             label='Detector 1', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 1],
-             label='Detector 2', marker='s', linewidth=1, markevery=20)
-
-    # Add value annotations
-    '''for t in timesteps:
-        plt.annotate(f'{electrons_per_detector_time[t, 0]:.2e}',
-                     (t, electrons_per_detector_time[t, 0]),
-                     textcoords="offset points",
-                     xytext=(0,10),
-                     ha='center',
-                     rotation=45)
-        plt.annotate(f'{electrons_per_detector_time[t, 1]:.2e}',
-                     (t, electrons_per_detector_time[t, 1]),
-                     textcoords="offset points",
-                     xytext=(0,-15),
-                     ha='center',
-                     rotation=45)'''
-
-    plt.title("Electrons Captured by Detectors 1 and 2 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_1_2_over_time(VDB-120).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create figure for detectors 3 and 4
-    plt.figure(figsize=(12, 8))
-
-    # Plot detectors 3 and 4
-    plt.plot(timesteps, electrons_per_detector_time[:, 2],
-             label='Detector 3', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 3],
-             label='Detector 4', marker='s', linewidth=1, markevery=20)
-
-    # Add value annotations
-    '''for t in timesteps:
-        plt.annotate(f'{electrons_per_detector_time[t, 2]:.2e}',
-                     (t, electrons_per_detector_time[t, 2]),
-                     textcoords="offset points",
-                     xytext=(0,10),
-                     ha='center',
-                     rotation=45)
-        plt.annotate(f'{electrons_per_detector_time[t, 3]:.2e}',
-                     (t, electrons_per_detector_time[t, 3]),
-                     textcoords="offset points",
-                     xytext=(0,-15),
-                     ha='center',
-                     rotation=45)'''
-
-    plt.title("Electrons Captured by Detectors 3 and 4 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_3_4_over_time(VDB-120).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-if __name__ == "__main__":
-    results = main()
\ No newline at end of file
diff --git a/apps/showcases/FreeSurface/raytracerVDB-multi-000003.py b/apps/showcases/FreeSurface/raytracerVDB-multi-000003.py
deleted file mode 100644
index 5902838d7457a19307169eda7736849e41a0c48a..0000000000000000000000000000000000000000
--- a/apps/showcases/FreeSurface/raytracerVDB-multi-000003.py
+++ /dev/null
@@ -1,1797 +0,0 @@
-import pyopenvdb as vdb
-import os
-from math import floor
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.colors as colors
-from matplotlib.animation import FuncAnimation
-from typing import Tuple, List
-import scipy.ndimage as ndimage
-from scipy.ndimage import gaussian_filter
-from scipy.spatial.transform import Rotation as Ro
-from scipy.spatial.transform import Slerp
-from scipy.interpolate import PchipInterpolator
-from sympy.printing.tree import print_node
-
-
-def runRayTracer(x_num, y_num, z_num, fill_levels, current_timestep):
-
-    print("current_timestep", current_timestep)
-    # fill_levels = np.clip(fill_levels, 0, 1)
-    fill_levels = fill_levels.astype(np.float64)
-
-    # Define the tolerance
-    tolerance = 1e-8
-
-    # Check if any value in fill_levels is outside the [0, 1] range
-    out_of_range = np.logical_or(fill_levels < -tolerance, fill_levels > 1 + tolerance)
-    if np.any(out_of_range):
-        out_of_range_values = fill_levels[out_of_range]
-        out_of_range_indices = np.where(out_of_range)
-
-        for idx, value in zip(zip(*out_of_range_indices), out_of_range_values):
-            print(f"Index: {idx}, Value: {value}")
-
-        # raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    # Check for fill levels within the range [0, 1]
-    in_range = np.logical_and(fill_levels > 0, fill_levels < 1)
-    in_range_values = fill_levels[in_range]
-    in_range_indices = np.where(in_range)
-
-    # print("Fill level values within range (0 to 1):")
-    # for idx, value in zip(zip(*in_range_indices), in_range_values):
-    # if idx == (np.int64(309),):
-    # print(f"Index: {idx}, Value: {value}")
-
-    # Constants
-    pi: float = np.pi  # Mathematical constant π
-    e: float = 1.60217662e-19  # Elementary charge in Coulombs
-    beam_current: float = 3e-3  # Beam current in Amperes
-    dwell_time: float = 0.32e-6  # Dwell time in seconds
-    A: float = 21.5  # Approximation for Ti6Al4V
-    # Ti6Al4V = Ti6Al4V.create_Ti6Al4V(T)
-    # A: float = Ti6Al4V.atomic_number
-    # print('Ti6Al4V.elements: ', Ti6Al4V.elements)
-    # print('Ti6Al4V.composition: ', Ti6Al4V.composition)
-    # print('A: ', A)
-    dx: float = 3e-6  # Cell size in meters
-    dy: float = dx  # Cell size in meters
-    dz: float = dx  # Cell size in meters
-    threshold: float = 0  # Beam profile threshold
-    # The voltage at detector d can be computed using the transimpedance T (in units of V /A ) of a virtual amplifier in the context of the model
-    transimpedance: float = 1e5  # Transimpedance in Volts per Ampere
-    eV_threshold = 50  # Threshold energy in eV
-    threshold_energy = eV_threshold * e  # Threshold energy in joules
-    # Define the work distance (height of the electron beam gun)
-    # w_d = 12e-3  # Work distance in meters (272 mm)
-
-    # Domain setup (Section 2.1.1)
-    # Define the 3D domain for the simulation
-    x_min, x_max = -0.5 * x_num * dx, 0.5 * x_num * dx  # -10 mm to 10 mm
-    # print('x_min, x_max:', x_min, x_max)
-    y_min, y_max = -0.5 * y_num * dy, 0.5 * y_num * dy  # -10 mm to 10 mm
-    # print('y_min, y_max:', y_min, y_max)
-    z_min, z_max = 0, z_num * dz
-    # print('z_min, z_max:', z_min, z_max)
-    # x_num = int((x_max - x_min) / dx) + 1
-    # y_num = int((y_max - y_min) / dy) + 1
-    # z_num = int((z_max - z_min) / dz) + 1
-    # print(f"Domain dimensions: x={x_num}, y={y_num}, z={z_num}")
-    # domain = np.zeros((z_num, y_num, x_num))  # (z, y , x)
-    # print('domain shape: ', np.shape(domain))
-    # domain[0:2, :, :] = 1  # Solid cells
-    # domain[2, :, :] = 0.5  # Interface layer
-    fill_levels_reshaped = np.reshape(fill_levels, (z_num, y_num, x_num))
-    # fill_levels_reshaped = fill_levels
-    # print('fill_levels_reshaped:', fill_levels_reshaped)
-    out_of_range_fill_levels = np.logical_or(fill_levels_reshaped < -tolerance, fill_levels_reshaped > 1 + tolerance)
-    if np.any(out_of_range_fill_levels):
-        out_of_range_fill_level_values = fill_levels_reshaped[out_of_range_fill_levels]
-        out_of_range_fill_level_indices = np.where(out_of_range_fill_levels)
-
-        for idx, value in zip(zip(*out_of_range_fill_level_indices), out_of_range_fill_level_values):
-            print(f"Index: {idx}, Value: {value}")
-        raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    sigma = 0.0
-    # Different sigma for each dimension
-    # sigma = [0.0, 1.0, 0.5]  # [z, y, x]
-    fill_levels_smoothed = ndimage.gaussian_filter(fill_levels_reshaped, sigma=sigma, mode='nearest', radius=1)
-
-    fill_levels_smoothed = np.clip(
-        fill_levels_smoothed,
-        a_min=np.float64(0.0),
-        a_max=np.float64(1.0)
-    )
-
-    # Check if values are still in bounds
-    if not (np.all(fill_levels_smoothed >= 0) and np.all(fill_levels_smoothed <= 1)):
-        raise ValueError("Smoothed fill levels out of bounds!")
-
-
-    def interpolate_normals(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """
-        Interpolate between two normals using spherical linear interpolation (SLERP)
-        """
-        # Input validation with better error messages
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-        if not (isinstance(_normal_below, np.ndarray) and isinstance(_normal_above, np.ndarray)):
-            raise ValueError("Normals must be numpy arrays")
-
-        # Ensure normals are normalized and correct type
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product with numerical stability
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Better thresholds
-        PARALLEL_THRESHOLD = 0.9999      # cos(1°) ≈ 0.9998
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Normals nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            _normal_interp = (1.0 - t) * _normal_below + t * _normal_above
-            return normalize_vector(_normal_interp)
-
-        # Case 2: Normals nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            # Find perpendicular vector more robustly
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-                if abs(np.dot(_normal_below, perp)) > 0.9:
-                    perp = np.array([0.0, 0.0, 1.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-
-            # Rotate 180 degrees around perp
-            angle = np.pi * t
-            _normal_interp = _normal_below * np.cos(angle) + perp * np.sin(angle)
-            return normalize_vector(_normal_interp)
-
-        # Case 3: Standard SLERP with better numerical stability
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        _normal_interp = scale0 * _normal_below + scale1 * _normal_above
-
-        return normalize_vector(_normal_interp)
-
-
-    def interpolate_fill_level_and_normal(_fill_level, surface_normals, _y, _x):
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-        # Find the indices where the fill level crosses 0.5 in either direction
-        indices_above = np.where((_fill_level[:, _y, _x][:-1] > 0.5) & (_fill_level[:, _y, _x][1:] <= 0.5))[0]
-        print('indices_above:', indices_above)
-        indices_below = np.where((_fill_level[:, _y, _x][:-1] <= 0.5) & (_fill_level[:, _y, _x][1:] > 0.5))[0]
-        print('indices_below:', indices_below)
-
-        # if len(indices_above) == 0 and len(indices_below) == 0:
-        # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-        # return None, None, None, None, None, None, None
-
-        if len(indices_above) > 0:
-            print("len(indices_above) > 0")
-            # Fill level crosses 0.5 from above to below
-            z_index = indices_above[0]
-            print('z_index:', z_index)
-            _fill_level_above = _fill_level[z_index, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-            _fill_level_below = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-        elif len(indices_below) > 0:
-            print("len(indices_below) > 0")
-            # Fill level crosses 0.5 from below to above
-            z_index = indices_below[0]
-            print('z_index:', z_index)
-            _fill_level_below = _fill_level[z_index, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-            _fill_level_above = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-        else:
-            # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-            print("No interface cells found")
-            return None, None, None, None, None, None, None
-
-        # Interpolate the z-coordinate where the fill level is 0.5
-        denominator = _fill_level_below - _fill_level_above
-        if abs(denominator) < 1e-6:
-            # Handle the case where the denominator is very close to zero
-            _z_interp = z_index + 0.5
-        else:
-            _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-        # _z_interp = z_index + (0.5 - bilinear_interpolate(_x, _y, z_index, _fill_level)) / (bilinear_interpolate(_x, _y, z_index+1, _fill_level) - bilinear_interpolate(_x, _y, z_index, _fill_level))
-        print('_z_interp:', _z_interp)
-
-        _z_height = (_z_interp + 0.5) * dz
-        print('_z_height:', _z_height)
-
-        # Interpolate the surface normal at the interpolated z-coordinate
-        _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-        print('_normal_below:', _normal_below)
-        _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-        print('_normal_above:', _normal_above)
-
-        t = _z_interp - z_index
-
-        # Use spherical interpolation (slerp) for more accurate normal interpolation
-        dot_product = np.dot(_normal_below, _normal_above).astype(np.float64)
-        if dot_product > np.cos(np.deg2rad(1)):  # Threshold for using linear interpolation
-            # If normals are very close, use linear interpolation
-            _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-        else:
-            # Use spherical interpolation
-            theta = np.arccos(dot_product)
-            sin_theta = np.sin(theta)
-            _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + (np.sin(t*theta) / sin_theta) * _normal_above
-            # _normal_interp = quaternion_interpolate(_normal_below, _normal_above, t)
-
-        _normal_interp = normalize_vector(_normal_interp)
-        print('_normal_interp:', _normal_interp)
-
-        return _z_interp, _z_height, _fill_level_below, _fill_level_above, _normal_interp, _normal_below, _normal_above
-
-
-    def interpolate_fill_level_and_normal1(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom (reverse order)
-        for z in range(len(column)-2, -1, -1):  # Start from second-to-last index
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Check if we cross 0.5 threshold
-            if (fill_current - 0.5) * (fill_next - 0.5) <= 0:
-                # Found interface
-                z_index = z
-                _fill_level_above = fill_current
-                _fill_level_below = fill_next
-
-                print(f"Found interface at z={z_index}")
-                print(f"fill_level_above: {_fill_level_above}")
-                print(f"fill_level_below: {_fill_level_below}")
-
-                # Interpolate z position
-                denominator = _fill_level_below - _fill_level_above
-                if abs(denominator) < 1e-6:
-                    _z_interp = z_index + 0.5
-                else:
-                    _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normals
-                t = _z_interp - z_index
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, _fill_level_below, _fill_level_above, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal2(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Look for significant transition from liquid (>0.5) to gas (<0.2)
-            if fill_current > 0.5 and fill_next < 0.2:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_above: {fill_next}")  # gas phase
-                print(f"fill_level_below: {fill_current}")  # liquid phase
-
-                # Interpolate z position
-                denominator = fill_next - fill_current
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - fill_current) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, fill_next, fill_current, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal3(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Look for liquid-gas interface (current > 0, next = 0)
-            if current_fill > 0 and next_fill == 0:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_below: {current_fill}")  # liquid phase
-                print(f"fill_level_above: {next_fill}")     # gas phase
-
-                # Interface is at z since this is the last liquid cell
-                denominator = next_fill - current_fill
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - current_fill) / denominator)
-
-                print('_z_interp', _z_interp)
-                _z_height = (_z_interp + 0.5) * dz
-                print("_z_height:", _z_height)
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-
-                # Use default normal if either normal is invalid
-                if np.any(np.isnan(_normal_below)) or np.any(np.isnan(_normal_above)):
-                    _normal_interp = np.array([0.0, 0.0, 1.0])
-                else:
-                    # Interpolate normal at interface
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                # _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                return _z_interp, _z_height, next_fill, current_fill, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal4(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Constants
-        INTERFACE_THRESHOLD = 0.5    # Standard interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Check for significant gradient and crossing of 0.5
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check if we cross the interface threshold
-                if ((current_fill > INTERFACE_THRESHOLD > next_fill) or
-                        (current_fill < INTERFACE_THRESHOLD < next_fill)):
-
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-
-                    # Calculate interpolation parameter
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        t_interface = 0.5
-                        _z_interp = z + 0.5
-                    else:
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        t_interface = np.clip(t_interface, 0.0, 1.0)  # Ensure t is in [0,1]
-                        _z_interp = z + t_interface
-
-                    print('_z_interp', _z_interp)
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    # Handle invalid normals
-                    if np.any(np.isnan(_normal_below)) or np.any(np.isnan(_normal_above)):
-                        _normal_interp = np.array([0.0, 0.0, 1.0])
-                    else:
-                        # Use clipped t_interface for normal interpolation
-                        _normal_interp = interpolate_normals(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-    def interpolate_fill_level_and_normal5(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check for interface crossing (VOF method)
-                crosses_interface = (
-                        (current_fill >= INTERFACE_THRESHOLD >= next_fill) or
-                        (current_fill <= INTERFACE_THRESHOLD <= next_fill)
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-                    # Linear interpolation with better numerical stability
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Calculate interpolation parameter
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels for context
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        # t_interface = np.clip(t_interface, 0.0, 1.0)
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel(_fill_levels: np.ndarray) -> np.ndarray:
-        """
-        Compute surface normals using Sobel filter with enhanced numerical precision.
-        """
-        # Convert input to high precision at the start
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        # Input validation
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        # Define tolerances for different checks
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Validate domain bounds with high precision
-        '''invalid_elements = np.logical_or(_fill_levels < -TOLERANCE['fill_level_bounds'],
-                                         _fill_levels > 1 + TOLERANCE['fill_level_bounds'])
-        if np.any(invalid_elements):
-            invalid_indices = np.where(invalid_elements)
-            for _z, _y, _x in zip(*invalid_indices):
-                _value = _fill_levels[_z, _y, _x]
-                print(f"Warning: Invalid value {_value} at [z={_z}, y={_y}, x={_x}]")'''
-
-        # Compute gradients using Sobel filter with explicit high precision
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)  # multiply by -1 to invert the surface normals to point in +z direction
-
-        # Stack gradients into normal vector array with maintained precision
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm with high precision
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-        nonzero_mask = norm > TOLERANCE['norm']
-        valid_mask = interface_mask & nonzero_mask
-
-        # Normalize vectors only where valid
-        # _n_S[valid_mask] /= norm[valid_mask][..., np.newaxis]
-        # _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-        _n_S_normalized[valid_mask] = _n_S[valid_mask] / norm[valid_mask, np.newaxis]
-
-        # Verify normalization
-        if not np.allclose(np.linalg.norm(_n_S_normalized[valid_mask], axis=-1),1.0, atol=TOLERANCE['normalization']):
-            raise ValueError("Normalization failed for some vectors")
-
-        # return _n_S
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel1(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        # filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        filled_mask = _fill_levels >= 1
-        # empty_mask = _fill_levels <= TOLERANCE['zero']
-        empty_mask = _fill_levels <= 0
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Default normal for sharp transitions (pointing up)
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0  # Default z-direction normal
-
-        # Expand valid_mask to match _n_S shape
-        valid_mask_expanded = valid_mask[..., np.newaxis]
-
-        # Where we have valid gradients, use them
-        gradient_mask = (norm > TOLERANCE['norm'])[..., np.newaxis]
-        mask = valid_mask_expanded & gradient_mask
-
-        # Normalize vectors where mask is True
-        norm_expanded = norm[..., np.newaxis]
-        _n_S_normalized = np.where(mask, _n_S / np.where(norm_expanded > 0, norm_expanded, 1.0), _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel2(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        empty_mask = _fill_levels <= TOLERANCE['zero']
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Initialize output array with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Create mask for valid gradients
-        gradient_mask = norm > TOLERANCE['norm']
-
-        # Combine masks and reshape for broadcasting
-        mask = valid_mask & gradient_mask
-
-        # Normalize only where mask is True
-        for i in range(3):  # For each component (x, y, z)
-            _n_S_normalized[..., i] = np.where(mask,
-                                               _n_S[..., i] / np.where(norm > 0, norm, 1.0),
-                                               np.nan)
-
-        return _n_S_normalized
-
-    def compute_surface_normals_sobel3(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def interpolate_normals_new(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """Interpolate between two normals using SLERP"""
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-
-        # Ensure normalized vectors
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Thresholds for special cases
-        PARALLEL_THRESHOLD = 0.9999
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            return normalize_vector((1.0 - t) * _normal_below + t * _normal_above)
-
-        # Case 2: Nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            angle = np.pi * t
-            return normalize_vector(_normal_below * np.cos(angle) + perp * np.sin(angle))
-
-        # Case 3: Standard SLERP
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        return normalize_vector(scale0 * _normal_below + scale1 * _normal_above)
-
-
-    def interpolate_fill_level_and_normal_new(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check for interface crossing (VOF method)
-                '''crosses_interface = (
-                        (current_fill >= INTERFACE_THRESHOLD - TOLERANCE >= next_fill) or
-                        (current_fill <= INTERFACE_THRESHOLD + TOLERANCE <= next_fill)
-                )'''
-                crosses_interface = (
-                    (current_fill >= (INTERFACE_THRESHOLD - TOLERANCE) and
-                     next_fill <= (INTERFACE_THRESHOLD + TOLERANCE))
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-                    # Linear interpolation with better numerical stability
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Calculate interpolation parameter
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels for context
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        # t_interface = np.clip(t_interface, 0.0, 1.0)
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals_new(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel_zeros(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel_nans(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter with NaN initialization"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-    # Compute surface normals (Section 2.1.1)
-    # n_S, (grad_x, grad_y, grad_z) = compute_surface_normals(domain)
-    n_S = compute_surface_normals_sobel_nans(fill_levels_smoothed)
-    # print('n_S: ', np.shape(n_S))
-
-    # Beam profile (Section 2.1)
-    # Define the Gaussian beam profile
-    sigma: float = 500e-6 / (4 * dx)  # Beam profile sigma
-    x = np.arange(-np.floor(min(x_num//4, 2*sigma)), 1+np.floor(min((x_num//4), 2*sigma)))
-    # print('x:', x)
-    y = np.arange(-floor(min(y_num//4, 2*sigma)), 1+floor(min((y_num//4), 2*sigma)))
-    # print('y:', y)
-    X, Y = np.meshgrid(x, y)
-    beam_profile = np.exp(-(X ** 2 + Y ** 2) / (2 * sigma ** 2))
-    beam_profile /= np.sum(beam_profile)  # Normalize beam profile
-    print('beam_profile shape: ', np.shape(beam_profile))
-
-    beam_size = np.shape(beam_profile)[0] * np.shape(beam_profile)[1]
-    # print('Beam size:', beam_size)
-
-    # Detector setup (Section 2.1.1)
-    # Define positions of the detectors
-    detector_height = 4 * z_max  # Detector height in meters (272 mm)
-    detector_spacing_x = 1.5 * dx * x_num  # Detector spacing in meters (127 mm)
-    detector_spacing_y = detector_spacing_x  # 1.5 * dy * y_num  # Detector spacing in meters (127 mm)
-    detector_diameter = 4 * dx  # Detector diameter in meters (50 mm)
-
-    # Define detector positions relative to the origin
-    detector_positions = np.array([
-        [0.5 * detector_spacing_x, 0, detector_height],
-        [-0.5 * detector_spacing_x, 0, detector_height],
-        [0, 0.5 * detector_spacing_y, detector_height],
-        [0, -0.5 * detector_spacing_y, detector_height]
-    ], dtype=np.float64)
-    # print('detector_positions:', detector_positions)
-
-
-    # Define detector vertices for solid angle calculation
-    def get_detector_vertices(detector_position: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
-        # Calculate the side length of the detector
-        side_length : float = np.sqrt(3) * (detector_diameter / 2)
-        height : float = np.sqrt(3) * (side_length / 2)
-
-        # Determine the orientation based on the detector position
-        if np.allclose(detector_position[:2], [0, 0]):
-            raise ValueError("Detector position cannot be at the origin (0, 0, 0)")
-        elif detector_position[0] > 0:  # Right detector
-            _v1 = detector_position + np.array([2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-height / 3, side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-height / 3, -side_length / 2, 0], dtype=np.float64)
-        elif detector_position[0] < 0:  # Left detector
-            _v1 = detector_position + np.array([-2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([height / 3, -side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([height / 3, side_length / 2, 0], dtype=np.float64)
-        elif detector_position[1] > 0:  # Back detector
-            _v1 = detector_position + np.array([0, 2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-side_length / 2, -height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([side_length / 2, -height / 3, 0], dtype=np.float64)
-        elif detector_position[1] < 0:  # Front detector
-            _v1 = detector_position + np.array([0, -2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([side_length / 2, height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-side_length / 2, height / 3, 0], dtype=np.float64)
-        else:
-            raise ValueError("Invalid detector position. Must be in one of the four expected positions.")
-
-        return _v1, _v2, _v3
-
-
-    # Compute solid angle using Equation A1 (Appendix A)
-    def compute_solid_angle(vector1: np.ndarray, vector2: np.ndarray, vector3: np.ndarray) -> float:
-        # Input validation
-        if not isinstance(vector1, np.ndarray) or not isinstance(vector2, np.ndarray) or not isinstance(vector3, np.ndarray):
-            raise ValueError("Inputs must be numpy arrays")
-        if vector1.shape != (3,) or vector2.shape != (3,) or vector3.shape != (3,):
-            raise ValueError("Inputs must be 3D vectors")
-        if not np.isfinite(vector1).all() or not np.isfinite(vector2).all() or not np.isfinite(vector3).all():
-            raise ValueError("Inputs must be finite")
-
-        # Convert to high precision once
-        vectors = [vec.astype(np.float64) for vec in (vector1, vector2, vector3)]
-        cross_product = np.cross(vectors[1], vectors[2])
-        # Check if the cross product is nearly zero
-        if np.linalg.norm(cross_product) < 1e-16:
-            raise ValueError("Vectors are nearly coplanar")
-
-        # Compute solid angle
-        try:
-            # Compute the numerator of the solid angle formula
-            numerator: float = np.dot(vectors[0], cross_product)
-            # Check if the numerator is nearly zero
-            if np.abs(numerator) < 1e-16:
-                raise ValueError("Vectors are nearly parallel")
-
-            norms = [np.linalg.norm(v) for v in vectors]
-
-            # Compute the denominator of the solid angle formula
-            denominator = (
-                    norms[0] * norms[1] * norms[2] +
-                    np.dot(vectors[0], vectors[1]) * norms[2] +
-                    np.dot(vectors[0], vectors[2]) * norms[1] +
-                    np.dot(vectors[1], vectors[2]) * norms[0]
-            )
-
-            # Compute the solid angle in radians
-            solid_angle = 2 * np.arctan2(numerator, denominator)
-            # print("Solid angle:", solid_angle)
-            return solid_angle
-        except ZeroDivisionError:
-            raise ValueError("Error computing solid angle: division by zero")
-        except np.linalg.LinAlgError:
-            raise ValueError("Error computing solid angle: linear algebra error")
-
-
-    # BSE coefficient functions (Equations 11 and 10, Section 2.1.2)
-    def eta_0(_A: float) -> float:
-        """Equation 11 from the paper"""
-        _eta_0: float = -0.0254 + 0.016 * _A - 1.86e-4 * _A ** 2 + 8.3e-7 * _A ** 3
-        print('eta_0: ', _eta_0)
-        return _eta_0
-
-
-    # BSE Coefficient for single atomic targets, found to hold in the range of 10–100 keV
-    def eta(theta: float, _A: float) -> float:
-        """Equation 10 from the paper"""
-        B: float = 0.89
-        _eta: float = B * (eta_0(_A) / B) ** np.cos(theta)
-        print('eta: ', _eta)
-        return _eta
-
-
-    '''
-    # Compute the weighted average BSE coefficient for the alloy
-    def compute_weighted_eta(theta: float, alloy: Alloy) -> float:
-        """Equation 12 from the paper"""
-        weighted_eta: float = 0
-        for element, weight_fraction in zip(alloy.elements, alloy.composition):
-            # print('element: ', element)
-            # print('weight_fraction: ', weight_fraction)
-            _A: float = element.atomic_number
-            # print('A: ', _A)
-            eta_i: float = eta(theta, _A)
-            # print('eta_i: ', eta_i)
-            weighted_eta += weight_fraction * eta_i
-            # print('weighted_eta: ', weighted_eta)
-        return weighted_eta
-    '''
-
-
-    # Correction function C(theta) (Equation 14, Section 2.1.3)
-    def C(theta: float) -> float:
-        print("theta1:", theta)
-        """Equation 14 from the paper"""
-        theta = np.rad2deg(theta)  # Ensure angle is in degrees for C function
-        _C: float = 1.01 - 4.06 * theta + 1.36e-5 * theta ** 2 - 1.71e-6 * theta ** 3
-        print('C: ', _C)
-        # Check if C is negative and raise an exception
-        if _C < 0:
-            raise ValueError(f"C function returned a negative value for theta: {theta}. "
-                             f"Please check the input and the C function implementation.")
-        return _C
-
-
-    # Scattering function g_BSE (Equation 13, Section 2.1.3)
-    def g_BSE(_theta: float, _alpha: float, _beta: float) -> float:
-        print("_theta, _alpha, _beta:", _theta, _alpha, _beta)
-        """Equation 13 from the paper"""
-        # k: float = np.rad2deg(_theta) / 13  # Correct implementation as per the paper
-        # print("k: ", k)
-        diffusive_part: float = (eta_0(A) / pi) * np.cos(_alpha)
-        print(np.cos(_alpha))
-        print("diffusive_part:", diffusive_part)
-        # reflective_part: float = ((eta(_theta, A) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-        # if np.abs(reflective_part) < 1e-10:
-        # reflective_part = 0
-        # print("reflective_part:", reflective_part)
-
-        # Compute a weighted average BSE coefficient for the alloy
-        # reflective_part: float = ((compute_weighted_eta(_theta, Ti6Al4V) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-
-        # print('g_BSE: ', diffusive_part + reflective_part)
-        return diffusive_part  #+ reflective_part
-
-
-    # Calculate the beam direction vector p_E
-    def calculate_p_E(_beam_x, _beam_y):
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        # Calculate the vector from the beam gun to the beam location
-        beam_vector = np.array([_beam_x * dx, _beam_y * dy, -detector_height], dtype=np.float64)
-        return normalize_vector(beam_vector)
-
-    def calculate_p_E1(_beam_x, _beam_y, _height):
-        """Calculate normalized beam direction vector from gun to surface point
-
-        Args:
-            _beam_x: Beam x-position relative to center (in cells)
-            _beam_y: Beam y-position relative to center (in cells)
-            _height: Height difference between gun and surface point (m)
-        """
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        print("_height:", _height)
-
-        # Calculate vector components
-        x_component = _beam_x * dx  # Convert cells to meters
-        y_component = _beam_y * dy  # Convert cells to meters
-        z_component = _height       # Already in meters
-
-        # Create beam vector (from gun to surface point)
-        beam_vector = np.array([x_component, y_component, z_component], dtype=np.float64)
-
-        # Verify downward direction
-        if normalize_vector(beam_vector)[2] > 0:
-            raise ValueError("Beam vector pointing upward")
-
-        # Normalize and return
-        return normalize_vector(beam_vector)
-
-
-    # Calculate phi as the angle between p_E and the negative z-axis
-    def calculate_phi(_p_E) -> float:
-        z_axis = np.array([0.0, 0.0, -1.0], dtype=np.float64)
-
-        # Check if _p_E is approximately equal to the negative z-axis
-        '''if np.allclose(_p_E, z_axis, rtol=1e-8, atol=1e-8):
-            _phi_rad = 0.0
-        else:
-            # Calculate the angle between _p_E and the z-axis using arccos
-            _phi_rad = calculate_angle(_p_E, z_axis)'''
-        _phi_rad = calculate_angle(_p_E, z_axis)
-        print(f"calculate_phi({_p_E}, {_phi_rad}):")
-        return _phi_rad
-
-
-    def set_beam_movement(_x_center, _y_center, _x_offset, _y_offset, _movement_type):
-        if _movement_type == 'X':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            _beam_y_start = _y_center + _y_offset
-            _beam_y_end = _y_center + _y_offset + 1
-        elif _movement_type == 'Y':
-            _beam_x_start = _x_center + _x_offset
-            _beam_x_end = _x_center + _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        elif _movement_type == 'XY':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        else:
-            raise ValueError("movement_type must be 'X' or 'Y' or 'XY'")
-
-        return _beam_x_start, _beam_x_end, _beam_y_start, _beam_y_end
-
-
-    # Initialize arrays to store electron counts
-    electrons_per_detector_per_step = np.zeros((x_num, y_num, len(detector_positions)))
-    total_electrons_per_step = np.zeros((x_num, y_num))
-
-    # Additional arrays to capture detailed information
-    electrons_per_detector_per_beam_profile = np.zeros(
-        (beam_profile.shape[0], beam_profile.shape[1], len(detector_positions)))
-    total_electrons_per_beam_profile = np.zeros((beam_profile.shape[0], beam_profile.shape[1]))
-
-    # Calculate the center indices
-    x_center = x_num // 2
-    y_center = y_num // 2
-    # print('x_center, y_center:', x_center, y_center)
-
-    x_offset: int = 0
-    y_offset: int = 0
-    movement_type: str = 'Y'
-
-    # Beam X-Movement
-    # beam_x_start, beam_x_end = x_center - x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center + y_offset, y_center + y_offset + 1
-
-    # Beam Y-Movement
-    # beam_x_start, beam_x_end = x_center + x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center - y_offset, y_center + y_offset + 1
-
-    # For X or Y Movement
-    beam_x_start, beam_x_end, beam_y_start, beam_y_end = set_beam_movement(
-        x_center, y_center, x_offset, y_offset, movement_type)
-
-    # Determine the step size based on the direction of movement
-    step_x = 1 if beam_x_start <= beam_x_end else -1
-    step_y = 1 if beam_y_start <= beam_y_end else -1
-
-    # print(beam_x_start, beam_x_end, beam_y_start, beam_y_end, step_x, step_y)
-
-
-    z_heights = np.zeros(beam_size)
-    fill_level_b = np.zeros(beam_size)
-    fill_level_a = np.zeros(beam_size)
-    normals = np.zeros((beam_size, 3))
-    normals_b = np.zeros((beam_size, 3))
-    normals_a = np.zeros((beam_size, 3))
-    cell_counter = 0
-
-
-    def normalize_vector(vector: np.ndarray) -> np.ndarray:
-        """Normalize a 3D vector."""
-        vector = vector.astype(np.float64)  # Convert to float64
-        norm = np.linalg.norm(vector)
-        if norm < 1e-18:  # Avoid division by zero
-            return vector  # Return original if nearly zero
-        return vector / norm  # Normalize
-
-
-    def calculate_angle(_v1: np.ndarray, _v2: np.ndarray) -> float:
-        """Calculate the angle between two normalized vectors."""
-        _v1 = normalize_vector(_v1)
-        _v2 = normalize_vector(_v2)
-
-        dot_product = np.clip(np.dot(_v1, _v2), -1.0, 1.0)
-
-        return np.arccos(dot_product)  # Return angle in radians
-
-
-    # Main simulation loop to iterate over the simulation domain
-    for beam_x in range(beam_x_start, beam_x_end, step_x):
-        for beam_y in range(beam_y_start, beam_y_end, step_y):
-            beam_loc = (beam_x, beam_y)
-            print(f"Beam center is at domain location: {beam_loc}")
-
-            # Calculate the beam direction vector p_E
-            # >> p_E = calculate_p_E(beam_x - x_center, beam_y - y_center)
-            # >> print('p_E: ', p_E)
-
-            # Calculate phi as the angle between p_E and the negative z-axis
-            # phi_rad = np.arctan2(np.sqrt(dx**2 + dy**2), w_d)
-            # >> phi_rad = calculate_phi(p_E)
-            # print(f"phi_rad: {phi_rad}")
-
-            # Reset counters for this beam location
-            total_electrons: int = 0
-            electrons_per_detector = np.zeros(len(detector_positions))
-
-            # Iterate over the beam profile
-            # for profile_x in [161]:
-            for profile_x in range(beam_profile.shape[0]):
-                # for profile_y in [42]:
-                for profile_y in range(beam_profile.shape[1]):
-                    print(f"Beam profile shape ({profile_x}, {profile_y})")
-                    domain_x = beam_loc[0] - beam_profile.shape[0] // 2 + profile_x
-                    domain_y = beam_loc[1] - beam_profile.shape[1] // 2 + profile_y
-                    print(f"Domain ({domain_x}, {domain_y})")
-
-                    # p_E = calculate_p_E(domain_x - x_center, domain_y - y_center)
-                    # print('p_E: ', p_E)
-                    # phi_rad = calculate_phi(p_E)
-
-                    if (0 <= domain_x < x_num) and (0 <= domain_y < y_num):
-                        if beam_profile[profile_x, profile_y] > threshold:
-                            print('finding interface_z_indices')
-                            # print('interface_z_indices:', interface_z)
-                            z_interp, z_height, fill_level_below, fill_level_above, normal_interp, normals_below, normals_above \
-                                = interpolate_fill_level_and_normal_new(fill_levels_smoothed, n_S, domain_y, domain_x)
-                            if z_interp is not None:
-                                z_heights[cell_counter] = z_height
-                                fill_level_b[cell_counter] = fill_level_below
-                                fill_level_a[cell_counter] = fill_level_above
-                                normals[cell_counter] = normal_interp
-                                normals_b[cell_counter] = normals_below
-                                normals_a[cell_counter] = normals_above
-                                cell_counter += 1
-                                n_S_local = normal_interp
-                                print('n_S_local:', n_S_local)
-                                if not np.allclose(np.linalg.norm(n_S_local), 1.0, atol=1e-5):
-                                    raise ValueError(f"n_S_local is not normalized, {np.linalg.norm(n_S_local)}")
-
-                                p_E = calculate_p_E1(domain_x - x_center, domain_y - y_center, -(detector_height-z_height))
-                                print('p_E: ', p_E)
-
-                                theta_local = calculate_angle(-p_E, n_S_local)
-                                print('theta_local:', theta_local)
-                                # if theta_local < 0.0 or theta_local > 0.011:
-                                # raise ValueError(f"theta_local value should be 0.0, found {theta_local}")
-                                # theta_local: 0.016
-
-                                scattering_point = np.array([
-                                    (domain_x - beam_x) * dx,
-                                    (domain_y - beam_y) * dy,
-                                    z_height
-                                ], dtype=np.float64)
-                                print(f'scattering_point ({domain_x}, {domain_y}, {z_interp}):', scattering_point)
-
-                                for det_idx, det_pos in enumerate(detector_positions):
-                                    # print(f"Detector {det_idx + 1} position: {det_pos}")
-                                    det_idx: int
-                                    # det_pos: np.ndarray[float]
-                                    print(f'det_pos[{det_idx + 1}]: {det_pos}')
-
-                                    # Calculate the vector from the scattering point to the detector
-                                    det_vec = det_pos - scattering_point
-                                    det_vec = normalize_vector(det_vec)
-                                    print(f'det_vec[{det_idx + 1}]: {det_vec}')
-                                    if not np.allclose(np.linalg.norm(det_vec), 1.0):
-                                        raise ValueError("det_vec is not normalized.")
-
-                                    # alpha = np.arccos(np.clip(np.dot(det_vec, n_S_local), -1.0, 1.0))
-                                    alpha = calculate_angle(det_vec, n_S_local)
-                                    print(f'alpha: {alpha}')
-
-                                    if alpha > np.pi / 2:
-                                        continue
-
-                                    p_E_reflected = p_E - 2.0 * np.dot(p_E, n_S_local) * n_S_local
-                                    # p_E_reflected = np.where(np.abs(p_E_reflected) < 1e-8, 0.0, p_E_reflected)
-                                    p_E_reflected = normalize_vector(p_E_reflected)
-                                    print(f'p_E_reflected: {p_E_reflected}')
-
-                                    # beta = np.arccos(np.clip(np.dot(p_E_reflected, det_vec), -1.0, 1.0))
-                                    beta = calculate_angle(p_E_reflected, det_vec)
-                                    print(f'beta: {beta}')
-
-                                    # Apply energy threshold
-                                    # electron_energy = N_0 * e * g_bse_value
-                                    # print('electron_energy: ', electron_energy)
-
-                                    # Calculate the initial energy of the primary electrons (E_0)
-                                    V_acc = 60e3  # Accelerating voltage in Volts
-                                    E_0 = V_acc * e  # Initial energy of primary electrons in joules
-
-                                    # Calculate the energy of the backscatter electrons
-                                    E_BSE = E_0 * eta(theta_local, A)
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # E_BSE = E_0 * compute_weighted_eta(theta_local, Ti6Al4V)
-                                    # print('E_BSE: ', E_BSE)
-
-                                    # Bifurcation logic for PE and SE
-                                    # if E_BSE > threshold_energy:
-                                    # Backscatter electrons (BSE)
-                                    # Compute scattering function g_bse_value
-                                    g_bse_value = g_BSE(theta_local, alpha, beta)
-                                    print(f'g_bse_value: {g_bse_value}')
-
-                                    # Ensure g_bse_value is non-negative
-                                    if g_bse_value <= 0:
-                                        raise ValueError(f"g_bse_value is {g_bse_value} (zero negative), check calculations.")
-
-                                    # Compute solid angle (Equation A1)
-                                    v1, v2, v3 = get_detector_vertices(det_pos)
-                                    normal = np.cross(v2 - v1, v3 - v1)
-                                    normal = normalize_vector(normal)
-
-                                    dOmega = compute_solid_angle(v1 - scattering_point,
-                                                                 v2 - scattering_point,
-                                                                 v3 - scattering_point)
-                                    print(f"Detector {det_idx + 1} solid angle: {dOmega}")
-
-                                    # Compute number of electrons collected by the detector (Equation A2)
-                                    # The beam ray stays on each grid point ij for the dwell time t_d and distributes N_0 PE into it
-                                    N_0 = beam_current * dwell_time / e  # Equation 3, Section 2.1.1
-                                    print("N_0:", N_0)
-
-                                    # There are N1 electrons in the chamber after the 1st scattering event
-                                    N_1 = N_0 * eta(theta_local, A)  # Equation 4, Section 2.1.1
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # N_1 = N_0 * compute_weighted_eta(theta_local, Ti6Al4V)  # Equation 4, Section 2.1.1
-                                    print("N_1:", N_1)
-
-                                    # The number of electrons N_1_d collected by a general detector d
-                                    N_1_d = N_1 * np.sum(g_bse_value * dOmega)  # Equation 5, Section 2.1.1
-
-                                    # Accumulate electrons per detector for this beam profile point
-                                    electrons_per_detector_per_beam_profile[profile_x, profile_y, det_idx] += N_1_d
-
-                                    # Accumulate electrons per detector for this domain point
-                                    electrons_per_detector_per_step[beam_x, beam_y, det_idx] += N_1_d
-
-                                    # Add to total electrons for this beam profile point
-                                    total_electrons_per_beam_profile[profile_x, profile_y] += N_1_d
-
-                                    # Add to total electrons for this domain point
-                                    total_electrons_per_step[beam_x, beam_y] += N_1_d
-
-                                    # Accumulate electrons per detector
-                                    electrons_per_detector[det_idx] += N_1_d
-
-                                    # Add to total electrons
-                                    total_electrons += N_1_d
-
-                                    # print(f"After addition: electrons_per_detector[{det_idx + 1}]: {electrons_per_detector[det_idx]}")
-                            else:
-                                print(f"No interface found at ({domain_x}, {domain_y})")
-
-                    # Print detailed information for this beam profile point
-                    # print(f"Electrons per detector at this beam profile point ({profile_x}, {profile_y}): {electrons_per_detector_per_beam_profile[profile_x, profile_y]}")
-                    # print(f"Total electrons at this beam profile point ({profile_x}, {profile_y}): {total_electrons_per_beam_profile[profile_x, profile_y]}")
-
-            # Print detailed information for this domain point after iterating over the beam profile
-            # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-            # print(f"Total electrons at this domain point ({beam_x}, {beam_y}): {total_electrons_per_step[beam_x, beam_y]}")
-
-            # print("Shape of electrons_per_detector_per_step:", electrons_per_detector_per_step.shape)
-            # print("Number of lines:", len(lines))
-
-    # Compute final results
-    total_electrons_all_steps = np.sum(total_electrons_per_step)
-    total_electrons_per_detector = np.sum(electrons_per_detector_per_step, axis=(0, 1))
-
-    # Print final results
-    # print(f"Total electrons hitting all detectors: {total_electrons_all_steps}")
-    # for i, electrons in enumerate(total_electrons_per_detector):
-    # print(f"Total electrons hitting detector {i + 1}: {electrons}")
-
-    # for beam_x in range(beam_x_start, beam_x_end, step_x):
-    # for beam_y in range(beam_y_start, beam_y_end, step_y):
-    # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-    # print('end simulation')
-    # print(electrons_per_detector_per_step)
-
-    # print("total_electrons:", total_electrons, "at timestep", current_timestep)
-    # print("electrons_per_det:", electrons_per_det, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc:", electrons_per_beam_loc, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc_per_det:", electrons_per_beam_loc_per_det, "at timestep", current_timestep)
-    return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, normals, normals_b, normals_a
-
-def read_fill_levels(filepath):
-    """Read only the FillingFrac grid from VDB file"""
-    try:
-        # Try to read just the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        print(f"Grid dimensions: {dims}")
-
-        # Create array
-        fill_array = np.zeros(dims, dtype=np.float64)
-
-        # Copy data
-        grid.copyToArray(fill_array)
-
-        # Transpose to ray tracer's expected order (z, y, x)
-        fill_array = np.transpose(fill_array, (2, 1, 0))
-
-        # Create standardized array with 256 z cells
-        standardized_array = np.zeros((256, dims[1], dims[0]), dtype=np.float64)
-
-        # Copy data from original array to standardized array
-        standardized_array[:fill_array.shape[0], :, :] = fill_array
-
-        # Apply Gaussian smoothing
-        sigma = 1.0
-        standardized_array_smoothed = ndimage.gaussian_filter(standardized_array, sigma=sigma, mode='nearest', radius=1)
-
-        # Print values
-        x = 157
-        y = 196
-        print(f"\nFill levels at ({x}, {y}):")
-        print("z_index | fill_level")
-        print("-" * 20)
-
-        # Get fill levels
-        fill_levels = standardized_array_smoothed[:, y, x]
-        valid_levels = fill_levels[(fill_levels > 0) & (fill_levels < 1)]
-
-        if len(valid_levels) > 0:
-            for z, val in enumerate(fill_levels):
-                if 0 <= val <= 1:
-                    print(f"{z:7d} | {val:.6f}")
-        else:
-            print("No fill level found between 0 and 1")
-
-        return standardized_array_smoothed
-
-    except Exception as e:
-        print(f"Error reading {filepath}")
-        print(f"Error details: {str(e)}")
-        try:
-            metadata = vdb.readMetadata(filepath)
-            print("\nFile metadata:")
-            for key, value in metadata.items():
-                print(f"{key}: {value}")
-        except Exception as meta_e:
-            print(f"Error reading metadata: {str(meta_e)}")
-        return None
-
-def process_single_timestep(filepath, timestep):
-    """Process a single VDB file and run ray tracer"""
-    try:
-        # Read the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        x_num, y_num = dims[0], dims[1]
-        z_num = 256  # dims[2] # Fixed z dimension
-        print(f"Domain dimensions: {x_num} x {y_num} x {z_num}")
-
-        # Read fill levels
-        fill_levels = read_fill_levels(filepath)
-        if fill_levels is None:
-            raise ValueError("Could not read fill levels from VDB file")
-
-        if fill_levels is not None:
-            print("\nSuccessfully read fill levels")
-            print(f"Array shape: {np.shape(fill_levels)}")
-
-            try:
-                # Convert to numpy array if not already
-                fill_array = np.asarray(fill_levels)
-
-                # Get non-zero elements
-                non_zero = fill_array[fill_array > 0]
-
-                # Get interface cells (0 < fill_level < 1)
-                interface_cells = fill_array[(fill_array > 0) & (fill_array < 1)]
-
-                # Print statistics
-                if len(non_zero) > 0:
-                    print("\nNon-zero cells statistics:")
-                    print(f"Number of non-zero cells: {len(non_zero)}")
-                    print(f"Min non-zero value: {np.min(non_zero):.6f}")
-                    print(f"Max value: {np.max(fill_array):.6f}")
-
-                if len(interface_cells) > 0:
-                    print("\nInterface cells statistics:")
-                    interface_percentage = (len(interface_cells) * 100.0) / len(non_zero)
-                    print(f"Number of interface cells: {len(interface_cells)} ({interface_percentage:.3f}%)")
-                    print(f"Min interface value: {np.min(interface_cells):.6f}")
-                    print(f"Max interface value: {np.max(interface_cells):.6f}")
-                    print(f"Mean interface value: {np.mean(interface_cells):.6f}")
-                else:
-                    print("\nNo interface cells found (no fill levels between 0 and 1)")
-
-            except Exception as e:
-                print(f"Error processing array: {str(e)}")
-
-        # Run ray tracer
-        print(f"\nProcessing timestep {timestep}")
-        electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a = runRayTracer(
-            x_num,
-            y_num,
-            z_num,
-            # fill_levels.flatten(),
-            fill_levels,
-            timestep
-        )
-
-        return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a
-
-    except Exception as e:
-        print(f"Error processing timestep {timestep}: {str(e)}")
-        raise
-
-def main():
-    """Main function to process VDB files and run ray tracer"""
-    # Directory containing VDB files
-    directory = "/local/ca00xebo/softwares/KiSSAM/Markl-plate"
-
-    # Get sorted list of VDB files
-    vdb_files = []
-    for f in os.listdir(directory):
-        if f.startswith('geometry-000003') and f.endswith('.vdb'):
-            try:
-                # Extract timestep number from filename
-                timestep = int(f.split('-')[1].split('.')[0])
-                vdb_files.append((timestep, f))
-            except (IndexError, ValueError) as e:
-                print(f"Skipping file {f}: {str(e)}")
-
-    # Sort by timestep
-    vdb_files.sort()  # sort based on timestep number
-
-    total_timesteps = len(vdb_files)
-    print(f"Found {total_timesteps} VDB files to process")
-    electrons_across_timesteps = np.zeros((total_timesteps, 4))
-
-    # Process each timestep
-    _results = []
-    for timestep, vdb_file in enumerate(vdb_files):
-        filepath = os.path.join(directory, vdb_file[1])  # vdb_file[1] contains the filename
-        try:
-            print(f"\nProcessing file {vdb_file[1]} (timestep {timestep})")
-
-            # Process this timestep
-            _result = process_single_timestep(filepath, timestep)
-
-            # Debug electron counts
-            print("\nElectron count debug:")
-            print(f"Shape of electrons_per_detector_per_step: {_result[0].shape}")
-            print(f"Min value: {np.min(_result[0])}")
-            print(f"Max value: {np.max(_result[0])}")
-            print(f"Mean value: {np.mean(_result[0])}")
-
-            # Store electron counts with debugging
-            total_electrons_for_detectors = np.sum(_result[0], axis=(0, 1))
-            print(f"Total electrons before assignment: {total_electrons_for_detectors}")
-
-            electrons_across_timesteps[timestep] = total_electrons_for_detectors
-            print(f"Electrons for timestep {timestep}:")
-            print(f"Raw values: {electrons_across_timesteps[timestep]}")
-            print(f"Sum: {np.sum(electrons_across_timesteps[timestep])}")
-            print("electrons_across_timesteps:", electrons_across_timesteps)
-
-            _results.append(_result)
-            print(f"Completed timestep {timestep}")
-
-        except Exception as e:
-            print(f"Error processing timestep {timestep}: {str(e)}")
-            raise
-
-        # Final debug output
-    print("\nFinal electrons_across_timesteps:")
-    print(f"Shape: {electrons_across_timesteps.shape}")
-    print(f"Min value: {np.min(electrons_across_timesteps)}")
-    print(f"Max value: {np.max(electrons_across_timesteps)}")
-    print(f"Mean value: {np.mean(electrons_across_timesteps)}")
-
-    # Plot results
-    plot_detector_results1(electrons_across_timesteps)
-
-    return _results
-
-def plot_detector_results(electrons_per_detector_time):
-    """Plot electron counts for each detector across timesteps"""
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot for each detector
-    for detector in range(4):
-        plt.plot(timesteps, electrons_per_detector_time[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-
-    # Add value annotations
-    for detector in range(4):
-        for t, value in zip(timesteps, electrons_per_detector_time[:, detector]):
-            plt.annotate(f'{value:.2e}',
-                         (t, value),
-                         textcoords="offset points",
-                         xytext=(0,10),
-                         ha='center',
-                         rotation=45)
-
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time(VDB-multi).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create additional visualization - normalized values
-    plt.figure(figsize=(12, 8))
-    normalized_electrons = electrons_per_detector_time / np.max(electrons_per_detector_time)
-
-    for detector in range(4):
-        plt.plot(timesteps, normalized_electrons[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Normalized Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Normalized Electron Count")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time_normalized(VDB-multi-000003).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-def plot_detector_results1(electrons_per_detector_time):
-    """
-    Plot electron counts for detector pairs across timesteps
-
-    Args:
-        electrons_per_detector_time: Array of shape (num_timesteps, 4) containing
-                                   electron counts for each detector at each timestep
-                                   :param electrons_per_detector_time:
-    """
-    # Create figure for detectors 1 and 2
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot detectors 1 and 2
-    plt.plot(timesteps, electrons_per_detector_time[:, 0],
-             label='Detector 1', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 1],
-             label='Detector 2', marker='s', linewidth=1, markevery=20)
-
-    plt.title("Electrons Captured by Detectors 1 and 2 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_1_2_over_time(VDB-multi-000003).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create figure for detectors 3 and 4
-    plt.figure(figsize=(12, 8))
-
-    # Plot detectors 3 and 4
-    plt.plot(timesteps, electrons_per_detector_time[:, 2],
-             label='Detector 3', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 3],
-             label='Detector 4', marker='s', linewidth=1, markevery=20)
-
-    plt.title("Electrons Captured by Detectors 3 and 4 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_3_4_over_time(VDB-multi-000003).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-if __name__ == "__main__":
-    results = main()
\ No newline at end of file
diff --git a/apps/showcases/FreeSurface/raytracerVDB-multi.py b/apps/showcases/FreeSurface/raytracerVDB-multi.py
deleted file mode 100644
index 6c807e4f8011310775c9d5baa94e3fd3cbb4eb83..0000000000000000000000000000000000000000
--- a/apps/showcases/FreeSurface/raytracerVDB-multi.py
+++ /dev/null
@@ -1,1798 +0,0 @@
-import pyopenvdb as vdb
-import os
-from math import floor
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.colors as colors
-from matplotlib.animation import FuncAnimation
-from typing import Tuple, List
-import scipy.ndimage as ndimage
-from scipy.ndimage import gaussian_filter
-from scipy.spatial.transform import Rotation as Ro
-from scipy.spatial.transform import Slerp
-from scipy.interpolate import PchipInterpolator
-from sympy.printing.tree import print_node
-
-
-def runRayTracer(x_num, y_num, z_num, fill_levels, current_timestep):
-
-    print("current_timestep", current_timestep)
-    # fill_levels = np.clip(fill_levels, 0, 1)
-    fill_levels = fill_levels.astype(np.float64)
-
-    # Define the tolerance
-    tolerance = 1e-8
-
-    # Check if any value in fill_levels is outside the [0, 1] range
-    out_of_range = np.logical_or(fill_levels < -tolerance, fill_levels > 1 + tolerance)
-    if np.any(out_of_range):
-        out_of_range_values = fill_levels[out_of_range]
-        out_of_range_indices = np.where(out_of_range)
-
-        for idx, value in zip(zip(*out_of_range_indices), out_of_range_values):
-            print(f"Index: {idx}, Value: {value}")
-
-        # raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    # Check for fill levels within the range [0, 1]
-    in_range = np.logical_and(fill_levels > 0, fill_levels < 1)
-    in_range_values = fill_levels[in_range]
-    in_range_indices = np.where(in_range)
-
-    # print("Fill level values within range (0 to 1):")
-    # for idx, value in zip(zip(*in_range_indices), in_range_values):
-    # if idx == (np.int64(309),):
-    # print(f"Index: {idx}, Value: {value}")
-
-    # Constants
-    pi: float = np.pi  # Mathematical constant π
-    e: float = 1.60217662e-19  # Elementary charge in Coulombs
-    beam_current: float = 3e-3  # Beam current in Amperes
-    dwell_time: float = 0.32e-6  # Dwell time in seconds
-    A: float = 21.5  # Approximation for Ti6Al4V
-    # Ti6Al4V = Ti6Al4V.create_Ti6Al4V(T)
-    # A: float = Ti6Al4V.atomic_number
-    # print('Ti6Al4V.elements: ', Ti6Al4V.elements)
-    # print('Ti6Al4V.composition: ', Ti6Al4V.composition)
-    # print('A: ', A)
-    dx: float = 3e-6  # Cell size in meters
-    dy: float = dx  # Cell size in meters
-    dz: float = dx  # Cell size in meters
-    threshold: float = 0  # Beam profile threshold
-    # The voltage at detector d can be computed using the transimpedance T (in units of V /A ) of a virtual amplifier in the context of the model
-    transimpedance: float = 1e5  # Transimpedance in Volts per Ampere
-    eV_threshold = 50  # Threshold energy in eV
-    threshold_energy = eV_threshold * e  # Threshold energy in joules
-    # Define the work distance (height of the electron beam gun)
-    # w_d = 12e-3  # Work distance in meters (272 mm)
-
-    # Domain setup (Section 2.1.1)
-    # Define the 3D domain for the simulation
-    x_min, x_max = -0.5 * x_num * dx, 0.5 * x_num * dx  # -10 mm to 10 mm
-    # print('x_min, x_max:', x_min, x_max)
-    y_min, y_max = -0.5 * y_num * dy, 0.5 * y_num * dy  # -10 mm to 10 mm
-    # print('y_min, y_max:', y_min, y_max)
-    z_min, z_max = 0, z_num * dz
-    # print('z_min, z_max:', z_min, z_max)
-    # x_num = int((x_max - x_min) / dx) + 1
-    # y_num = int((y_max - y_min) / dy) + 1
-    # z_num = int((z_max - z_min) / dz) + 1
-    # print(f"Domain dimensions: x={x_num}, y={y_num}, z={z_num}")
-    # domain = np.zeros((z_num, y_num, x_num))  # (z, y , x)
-    # print('domain shape: ', np.shape(domain))
-    # domain[0:2, :, :] = 1  # Solid cells
-    # domain[2, :, :] = 0.5  # Interface layer
-    fill_levels_reshaped = np.reshape(fill_levels, (z_num, y_num, x_num))
-    # fill_levels_reshaped = fill_levels
-    # print('fill_levels_reshaped:', fill_levels_reshaped)
-    out_of_range_fill_levels = np.logical_or(fill_levels_reshaped < -tolerance, fill_levels_reshaped > 1 + tolerance)
-    if np.any(out_of_range_fill_levels):
-        out_of_range_fill_level_values = fill_levels_reshaped[out_of_range_fill_levels]
-        out_of_range_fill_level_indices = np.where(out_of_range_fill_levels)
-
-        for idx, value in zip(zip(*out_of_range_fill_level_indices), out_of_range_fill_level_values):
-            print(f"Index: {idx}, Value: {value}")
-        raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    sigma = 0.0
-    # Different sigma for each dimension
-    # sigma = [0.0, 1.0, 0.5]  # [z, y, x]
-    fill_levels_smoothed = ndimage.gaussian_filter(fill_levels_reshaped, sigma=sigma, mode='nearest', radius=1)
-
-    fill_levels_smoothed = np.clip(
-        fill_levels_smoothed,
-        a_min=np.float64(0.0),
-        a_max=np.float64(1.0)
-    )
-
-    # Check if values are still in bounds
-    if not (np.all(fill_levels_smoothed >= 0) and np.all(fill_levels_smoothed <= 1)):
-        raise ValueError("Smoothed fill levels out of bounds!")
-
-
-    def interpolate_normals(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """
-        Interpolate between two normals using spherical linear interpolation (SLERP)
-        """
-        # Input validation with better error messages
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-        if not (isinstance(_normal_below, np.ndarray) and isinstance(_normal_above, np.ndarray)):
-            raise ValueError("Normals must be numpy arrays")
-
-        # Ensure normals are normalized and correct type
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product with numerical stability
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Better thresholds
-        PARALLEL_THRESHOLD = 0.9999      # cos(1°) ≈ 0.9998
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Normals nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            _normal_interp = (1.0 - t) * _normal_below + t * _normal_above
-            return normalize_vector(_normal_interp)
-
-        # Case 2: Normals nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            # Find perpendicular vector more robustly
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-                if abs(np.dot(_normal_below, perp)) > 0.9:
-                    perp = np.array([0.0, 0.0, 1.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-
-            # Rotate 180 degrees around perp
-            angle = np.pi * t
-            _normal_interp = _normal_below * np.cos(angle) + perp * np.sin(angle)
-            return normalize_vector(_normal_interp)
-
-        # Case 3: Standard SLERP with better numerical stability
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        _normal_interp = scale0 * _normal_below + scale1 * _normal_above
-
-        return normalize_vector(_normal_interp)
-
-
-    def interpolate_fill_level_and_normal(_fill_level, surface_normals, _y, _x):
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-        # Find the indices where the fill level crosses 0.5 in either direction
-        indices_above = np.where((_fill_level[:, _y, _x][:-1] > 0.5) & (_fill_level[:, _y, _x][1:] <= 0.5))[0]
-        print('indices_above:', indices_above)
-        indices_below = np.where((_fill_level[:, _y, _x][:-1] <= 0.5) & (_fill_level[:, _y, _x][1:] > 0.5))[0]
-        print('indices_below:', indices_below)
-
-        # if len(indices_above) == 0 and len(indices_below) == 0:
-        # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-        # return None, None, None, None, None, None, None
-
-        if len(indices_above) > 0:
-            print("len(indices_above) > 0")
-            # Fill level crosses 0.5 from above to below
-            z_index = indices_above[0]
-            print('z_index:', z_index)
-            _fill_level_above = _fill_level[z_index, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-            _fill_level_below = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-        elif len(indices_below) > 0:
-            print("len(indices_below) > 0")
-            # Fill level crosses 0.5 from below to above
-            z_index = indices_below[0]
-            print('z_index:', z_index)
-            _fill_level_below = _fill_level[z_index, _y, _x]
-            print('_fill_level_below:', _fill_level_below)
-            _fill_level_above = _fill_level[z_index + 1, _y, _x]
-            print('_fill_level_above:', _fill_level_above)
-        else:
-            # Fill level does not cross 0.5 at the given (_x, _y) coordinate
-            print("No interface cells found")
-            return None, None, None, None, None, None, None
-
-        # Interpolate the z-coordinate where the fill level is 0.5
-        denominator = _fill_level_below - _fill_level_above
-        if abs(denominator) < 1e-6:
-            # Handle the case where the denominator is very close to zero
-            _z_interp = z_index + 0.5
-        else:
-            _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-        # _z_interp = z_index + (0.5 - bilinear_interpolate(_x, _y, z_index, _fill_level)) / (bilinear_interpolate(_x, _y, z_index+1, _fill_level) - bilinear_interpolate(_x, _y, z_index, _fill_level))
-        print('_z_interp:', _z_interp)
-
-        _z_height = (_z_interp + 0.5) * dz
-        print('_z_height:', _z_height)
-
-        # Interpolate the surface normal at the interpolated z-coordinate
-        _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-        print('_normal_below:', _normal_below)
-        _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-        print('_normal_above:', _normal_above)
-
-        t = _z_interp - z_index
-
-        # Use spherical interpolation (slerp) for more accurate normal interpolation
-        dot_product = np.dot(_normal_below, _normal_above).astype(np.float64)
-        if dot_product > np.cos(np.deg2rad(1)):  # Threshold for using linear interpolation
-            # If normals are very close, use linear interpolation
-            _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-        else:
-            # Use spherical interpolation
-            theta = np.arccos(dot_product)
-            sin_theta = np.sin(theta)
-            _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + (np.sin(t*theta) / sin_theta) * _normal_above
-            # _normal_interp = quaternion_interpolate(_normal_below, _normal_above, t)
-
-        _normal_interp = normalize_vector(_normal_interp)
-        print('_normal_interp:', _normal_interp)
-
-        return _z_interp, _z_height, _fill_level_below, _fill_level_above, _normal_interp, _normal_below, _normal_above
-
-
-    def interpolate_fill_level_and_normal1(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        # Validate input
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom (reverse order)
-        for z in range(len(column)-2, -1, -1):  # Start from second-to-last index
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Check if we cross 0.5 threshold
-            if (fill_current - 0.5) * (fill_next - 0.5) <= 0:
-                # Found interface
-                z_index = z
-                _fill_level_above = fill_current
-                _fill_level_below = fill_next
-
-                print(f"Found interface at z={z_index}")
-                print(f"fill_level_above: {_fill_level_above}")
-                print(f"fill_level_below: {_fill_level_below}")
-
-                # Interpolate z position
-                denominator = _fill_level_below - _fill_level_above
-                if abs(denominator) < 1e-6:
-                    _z_interp = z_index + 0.5
-                else:
-                    _z_interp = z_index + ((0.5 - _fill_level_above) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z_index, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z_index + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normals
-                t = _z_interp - z_index
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, _fill_level_below, _fill_level_above, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal2(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at the given (x,y) position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            fill_current = column[z]
-            fill_next = column[z + 1]
-
-            # Look for significant transition from liquid (>0.5) to gas (<0.2)
-            if fill_current > 0.5 and fill_next < 0.2:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_above: {fill_next}")  # gas phase
-                print(f"fill_level_below: {fill_current}")  # liquid phase
-
-                # Interpolate z position
-                denominator = fill_next - fill_current
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - fill_current) / denominator)
-
-                _z_height = (_z_interp + 0.5) * dz
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-                dot_product = np.dot(_normal_below, _normal_above)
-
-                if dot_product > np.cos(np.deg2rad(1)):
-                    _normal_interp = _normal_below + t * (_normal_above - _normal_below)
-                else:
-                    theta = np.arccos(dot_product)
-                    sin_theta = np.sin(theta)
-                    _normal_interp = (np.sin((1-t)*theta) / sin_theta) * _normal_below + \
-                                     (np.sin(t*theta) / sin_theta) * _normal_above
-
-                _normal_interp = normalize_vector(_normal_interp)
-
-                return _z_interp, _z_height, fill_next, fill_current, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal3(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals, starting from top"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals, {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Look for liquid-gas interface (current > 0, next = 0)
-            if current_fill > 0 and next_fill == 0:
-                print(f"Found interface at z={z}")
-                print(f"fill_level_below: {current_fill}")  # liquid phase
-                print(f"fill_level_above: {next_fill}")     # gas phase
-
-                # Interface is at z since this is the last liquid cell
-                denominator = next_fill - current_fill
-                if abs(denominator) < 1e-6:
-                    _z_interp = z + 0.5
-                else:
-                    _z_interp = z + ((0.5 - current_fill) / denominator)
-
-                print('_z_interp', _z_interp)
-                _z_height = (_z_interp + 0.5) * dz
-                print("_z_height:", _z_height)
-
-                # Get and interpolate normals
-                _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                # Interpolate normal
-                t = _z_interp - z
-
-                # Use default normal if either normal is invalid
-                if np.any(np.isnan(_normal_below)) or np.any(np.isnan(_normal_above)):
-                    _normal_interp = np.array([0.0, 0.0, 1.0])
-                else:
-                    # Interpolate normal at interface
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                # _normal_interp = interpolate_normals(_normal_below, _normal_above, t)
-
-                return _z_interp, _z_height, next_fill, current_fill, \
-                    _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-
-    def interpolate_fill_level_and_normal4(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        column = _fill_level[:, _y, _x]
-
-        # Constants
-        INTERFACE_THRESHOLD = 0.5    # Standard interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(column)-2, -1, -1):
-            current_fill = column[z]
-            next_fill = column[z + 1]
-
-            # Check for significant gradient and crossing of 0.5
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check if we cross the interface threshold
-                if ((current_fill > INTERFACE_THRESHOLD > next_fill) or
-                        (current_fill < INTERFACE_THRESHOLD < next_fill)):
-
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-
-                    # Calculate interpolation parameter
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        t_interface = 0.5
-                        _z_interp = z + 0.5
-                    else:
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        t_interface = np.clip(t_interface, 0.0, 1.0)  # Ensure t is in [0,1]
-                        _z_interp = z + t_interface
-
-                    print('_z_interp', _z_interp)
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    # Handle invalid normals
-                    if np.any(np.isnan(_normal_below)) or np.any(np.isnan(_normal_above)):
-                        _normal_interp = np.array([0.0, 0.0, 1.0])
-                    else:
-                        # Use clipped t_interface for normal interpolation
-                        _normal_interp = interpolate_normals(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        print("No interface found")
-        return None, None, None, None, None, None, None
-
-    def interpolate_fill_level_and_normal5(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check for interface crossing (VOF method)
-                crosses_interface = (
-                        (current_fill >= INTERFACE_THRESHOLD >= next_fill) or
-                        (current_fill <= INTERFACE_THRESHOLD <= next_fill)
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-                    # Linear interpolation with better numerical stability
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Calculate interpolation parameter
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels for context
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        # t_interface = np.clip(t_interface, 0.0, 1.0)
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel(_fill_levels: np.ndarray) -> np.ndarray:
-        """
-        Compute surface normals using Sobel filter with enhanced numerical precision.
-        """
-        # Convert input to high precision at the start
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        # Input validation
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        # Define tolerances for different checks
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Validate domain bounds with high precision
-        '''invalid_elements = np.logical_or(_fill_levels < -TOLERANCE['fill_level_bounds'],
-                                         _fill_levels > 1 + TOLERANCE['fill_level_bounds'])
-        if np.any(invalid_elements):
-            invalid_indices = np.where(invalid_elements)
-            for _z, _y, _x in zip(*invalid_indices):
-                _value = _fill_levels[_z, _y, _x]
-                print(f"Warning: Invalid value {_value} at [z={_z}, y={_y}, x={_x}]")'''
-
-        # Compute gradients using Sobel filter with explicit high precision
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)  # multiply by -1 to invert the surface normals to point in +z direction
-
-        # Stack gradients into normal vector array with maintained precision
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm with high precision
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-        nonzero_mask = norm > TOLERANCE['norm']
-        valid_mask = interface_mask & nonzero_mask
-
-        # Normalize vectors only where valid
-        # _n_S[valid_mask] /= norm[valid_mask][..., np.newaxis]
-        # _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-        _n_S_normalized[valid_mask] = _n_S[valid_mask] / norm[valid_mask, np.newaxis]
-
-        # Verify normalization
-        if not np.allclose(np.linalg.norm(_n_S_normalized[valid_mask], axis=-1),1.0, atol=TOLERANCE['normalization']):
-            raise ValueError("Normalization failed for some vectors")
-
-        # return _n_S
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel1(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        # filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        filled_mask = _fill_levels >= 1
-        # empty_mask = _fill_levels <= TOLERANCE['zero']
-        empty_mask = _fill_levels <= 0
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Default normal for sharp transitions (pointing up)
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0  # Default z-direction normal
-
-        # Expand valid_mask to match _n_S shape
-        valid_mask_expanded = valid_mask[..., np.newaxis]
-
-        # Where we have valid gradients, use them
-        gradient_mask = (norm > TOLERANCE['norm'])[..., np.newaxis]
-        mask = valid_mask_expanded & gradient_mask
-
-        # Normalize vectors where mask is True
-        norm_expanded = norm[..., np.newaxis]
-        _n_S_normalized = np.where(mask, _n_S / np.where(norm_expanded > 0, norm_expanded, 1.0), _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel2(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'fill_level_bounds': 1e-10,
-            'zero': 1e-10,
-            'norm': 1e-10,
-            'normalization': 1e-8
-        }
-
-        # Compute gradients
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64)
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64)
-        _grad_z = -1 * ndimage.sobel(_fill_levels, axis=0, output=np.float64)
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Compute norm
-        norm = np.linalg.norm(_n_S, axis=-1)
-
-        # Create masks for valid regions:
-        # 1. Interface cells (fill level between 0 and 1)
-        interface_mask = (_fill_levels > TOLERANCE['zero']) & (_fill_levels < 1 - TOLERANCE['zero'])
-
-        # 2. Cells adjacent to fill level transitions
-        filled_mask = _fill_levels >= 1 - TOLERANCE['zero']
-        empty_mask = _fill_levels <= TOLERANCE['zero']
-
-        # Check for transitions in z direction
-        z_transitions = np.zeros_like(_fill_levels, dtype=bool)
-        z_transitions[:-1, :, :] |= (filled_mask[:-1, :, :] & empty_mask[1:, :, :])
-        z_transitions[1:, :, :] |= (empty_mask[:-1, :, :] & filled_mask[1:, :, :])
-
-        # Combine masks
-        valid_mask = interface_mask | z_transitions
-
-        # Initialize output array with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Create mask for valid gradients
-        gradient_mask = norm > TOLERANCE['norm']
-
-        # Combine masks and reshape for broadcasting
-        mask = valid_mask & gradient_mask
-
-        # Normalize only where mask is True
-        for i in range(3):  # For each component (x, y, z)
-            _n_S_normalized[..., i] = np.where(mask,
-                                               _n_S[..., i] / np.where(norm > 0, norm, 1.0),
-                                               np.nan)
-
-        return _n_S_normalized
-
-    def compute_surface_normals_sobel3(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def interpolate_normals_new(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """Interpolate between two normals using SLERP"""
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-
-        # Ensure normalized vectors
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Thresholds for special cases
-        PARALLEL_THRESHOLD = 0.9999
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            return normalize_vector((1.0 - t) * _normal_below + t * _normal_above)
-
-        # Case 2: Nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            angle = np.pi * t
-            return normalize_vector(_normal_below * np.cos(angle) + perp * np.sin(angle))
-
-        # Case 3: Standard SLERP
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        return normalize_vector(scale0 * _normal_below + scale1 * _normal_above)
-
-
-    def interpolate_fill_level_and_normal_new(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        # Get fill levels at position
-        _column = _fill_level[:, _y, _x]
-
-        # Constants with physical meaning
-        INTERFACE_THRESHOLD = 0.5    # Standard VOF interface threshold
-        GRADIENT_THRESHOLD = 0.1     # Minimum gradient for interface detection
-        TOLERANCE = 1e-6            # Numerical tolerance
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            # Check for significant gradient
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Check for interface crossing (VOF method)
-                '''crosses_interface = (
-                        (current_fill >= INTERFACE_THRESHOLD - TOLERANCE >= next_fill) or
-                        (current_fill <= INTERFACE_THRESHOLD + TOLERANCE <= next_fill)
-                )'''
-                crosses_interface = (
-                    (current_fill >= (INTERFACE_THRESHOLD - TOLERANCE) and
-                     next_fill <= (INTERFACE_THRESHOLD + TOLERANCE))
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-                    # Linear interpolation with better numerical stability
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Calculate interpolation parameter
-                        t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-                        if not (0 <= t_interface <= 1):
-                            error_msg = (
-                                f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                f"Debug information:\n"
-                                f"  - z-index: {z}\n"
-                                f"  - current_fill: {current_fill:.6f}\n"
-                                f"  - next_fill: {next_fill:.6f}\n"
-                                f"  - denominator: {denominator:.6f}\n"
-                                f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                f"  - Position (y,x): ({_y}, {_x})\n"
-                                f"  - Fill level values around interface:\n"
-                            )
-                            # Nearby fill levels for context
-                            for i in range(max(0, z-2), min(len(_column), z+3)):
-                                error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-
-                            raise ValueError(error_msg)
-                        # t_interface = np.clip(t_interface, 0.0, 1.0)
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and validate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals_new(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel_zeros(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel_nans(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter with NaN initialization"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    # Compute surface normals (Section 2.1.1)
-    # n_S, (grad_x, grad_y, grad_z) = compute_surface_normals(domain)
-    n_S = compute_surface_normals_sobel_nans(fill_levels_smoothed)
-    # print('n_S: ', np.shape(n_S))
-
-    # Beam profile (Section 2.1)
-    # Define the Gaussian beam profile
-    sigma: float = 500e-6 / (4 * dx)  # Beam profile sigma
-    x = np.arange(-np.floor(min(x_num//4, 2*sigma)), 1+np.floor(min((x_num//4), 2*sigma)))
-    # print('x:', x)
-    y = np.arange(-floor(min(y_num//4, 2*sigma)), 1+floor(min((y_num//4), 2*sigma)))
-    # print('y:', y)
-    X, Y = np.meshgrid(x, y)
-    beam_profile = np.exp(-(X ** 2 + Y ** 2) / (2 * sigma ** 2))
-    beam_profile /= np.sum(beam_profile)  # Normalize beam profile
-    print('beam_profile shape: ', np.shape(beam_profile))
-
-    beam_size = np.shape(beam_profile)[0] * np.shape(beam_profile)[1]
-    # print('Beam size:', beam_size)
-
-    # Detector setup (Section 2.1.1)
-    # Define positions of the detectors
-    detector_height = 4 * z_max  # Detector height in meters (272 mm)
-    detector_spacing_x = 1.5 * dx * x_num  # Detector spacing in meters (127 mm)
-    detector_spacing_y = detector_spacing_x  # 1.5 * dy * y_num  # Detector spacing in meters (127 mm)
-    detector_diameter = 4 * dx  # Detector diameter in meters (50 mm)
-
-    # Define detector positions relative to the origin
-    detector_positions = np.array([
-        [0.5 * detector_spacing_x, 0, detector_height],
-        [-0.5 * detector_spacing_x, 0, detector_height],
-        [0, 0.5 * detector_spacing_y, detector_height],
-        [0, -0.5 * detector_spacing_y, detector_height]
-    ], dtype=np.float64)
-    # print('detector_positions:', detector_positions)
-
-
-    # Define detector vertices for solid angle calculation
-    def get_detector_vertices(detector_position: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
-        # Calculate the side length of the detector
-        side_length : float = np.sqrt(3) * (detector_diameter / 2)
-        height : float = np.sqrt(3) * (side_length / 2)
-
-        # Determine the orientation based on the detector position
-        if np.allclose(detector_position[:2], [0, 0]):
-            raise ValueError("Detector position cannot be at the origin (0, 0, 0)")
-        elif detector_position[0] > 0:  # Right detector
-            _v1 = detector_position + np.array([2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-height / 3, side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-height / 3, -side_length / 2, 0], dtype=np.float64)
-        elif detector_position[0] < 0:  # Left detector
-            _v1 = detector_position + np.array([-2 / 3 * height, 0, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([height / 3, -side_length / 2, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([height / 3, side_length / 2, 0], dtype=np.float64)
-        elif detector_position[1] > 0:  # Back detector
-            _v1 = detector_position + np.array([0, 2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([-side_length / 2, -height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([side_length / 2, -height / 3, 0], dtype=np.float64)
-        elif detector_position[1] < 0:  # Front detector
-            _v1 = detector_position + np.array([0, -2 / 3 * height, 0], dtype=np.float64)
-            _v2 = detector_position + np.array([side_length / 2, height / 3, 0], dtype=np.float64)
-            _v3 = detector_position + np.array([-side_length / 2, height / 3, 0], dtype=np.float64)
-        else:
-            raise ValueError("Invalid detector position. Must be in one of the four expected positions.")
-
-        return _v1, _v2, _v3
-
-
-    # Compute solid angle using Equation A1 (Appendix A)
-    def compute_solid_angle(vector1: np.ndarray, vector2: np.ndarray, vector3: np.ndarray) -> float:
-        # Input validation
-        if not isinstance(vector1, np.ndarray) or not isinstance(vector2, np.ndarray) or not isinstance(vector3, np.ndarray):
-            raise ValueError("Inputs must be numpy arrays")
-        if vector1.shape != (3,) or vector2.shape != (3,) or vector3.shape != (3,):
-            raise ValueError("Inputs must be 3D vectors")
-        if not np.isfinite(vector1).all() or not np.isfinite(vector2).all() or not np.isfinite(vector3).all():
-            raise ValueError("Inputs must be finite")
-
-        # Convert to high precision once
-        vectors = [vec.astype(np.float64) for vec in (vector1, vector2, vector3)]
-        cross_product = np.cross(vectors[1], vectors[2])
-        # Check if the cross product is nearly zero
-        if np.linalg.norm(cross_product) < 1e-16:
-            raise ValueError("Vectors are nearly coplanar")
-
-        # Compute solid angle
-        try:
-            # Compute the numerator of the solid angle formula
-            numerator: float = np.dot(vectors[0], cross_product)
-            # Check if the numerator is nearly zero
-            if np.abs(numerator) < 1e-16:
-                raise ValueError("Vectors are nearly parallel")
-
-            norms = [np.linalg.norm(v) for v in vectors]
-
-            # Compute the denominator of the solid angle formula
-            denominator = (
-                    norms[0] * norms[1] * norms[2] +
-                    np.dot(vectors[0], vectors[1]) * norms[2] +
-                    np.dot(vectors[0], vectors[2]) * norms[1] +
-                    np.dot(vectors[1], vectors[2]) * norms[0]
-            )
-
-            # Compute the solid angle in radians
-            solid_angle = 2 * np.arctan2(numerator, denominator)
-            # print("Solid angle:", solid_angle)
-            return solid_angle
-        except ZeroDivisionError:
-            raise ValueError("Error computing solid angle: division by zero")
-        except np.linalg.LinAlgError:
-            raise ValueError("Error computing solid angle: linear algebra error")
-
-
-    # BSE coefficient functions (Equations 11 and 10, Section 2.1.2)
-    def eta_0(_A: float) -> float:
-        """Equation 11 from the paper"""
-        _eta_0: float = -0.0254 + 0.016 * _A - 1.86e-4 * _A ** 2 + 8.3e-7 * _A ** 3
-        print('eta_0: ', _eta_0)
-        return _eta_0
-
-
-    # BSE Coefficient for single atomic targets, found to hold in the range of 10–100 keV
-    def eta(theta: float, _A: float) -> float:
-        """Equation 10 from the paper"""
-        B: float = 0.89
-        _eta: float = B * (eta_0(_A) / B) ** np.cos(theta)
-        print('eta: ', _eta)
-        return _eta
-
-
-    '''
-    # Compute the weighted average BSE coefficient for the alloy
-    def compute_weighted_eta(theta: float, alloy: Alloy) -> float:
-        """Equation 12 from the paper"""
-        weighted_eta: float = 0
-        for element, weight_fraction in zip(alloy.elements, alloy.composition):
-            # print('element: ', element)
-            # print('weight_fraction: ', weight_fraction)
-            _A: float = element.atomic_number
-            # print('A: ', _A)
-            eta_i: float = eta(theta, _A)
-            # print('eta_i: ', eta_i)
-            weighted_eta += weight_fraction * eta_i
-            # print('weighted_eta: ', weighted_eta)
-        return weighted_eta
-    '''
-
-
-    # Correction function C(theta) (Equation 14, Section 2.1.3)
-    def C(theta: float) -> float:
-        print("theta1:", theta)
-        """Equation 14 from the paper"""
-        theta = np.rad2deg(theta)  # Ensure angle is in degrees for C function
-        _C: float = 1.01 - 4.06 * theta + 1.36e-5 * theta ** 2 - 1.71e-6 * theta ** 3
-        print('C: ', _C)
-        # Check if C is negative and raise an exception
-        if _C < 0:
-            raise ValueError(f"C function returned a negative value for theta: {theta}. "
-                             f"Please check the input and the C function implementation.")
-        return _C
-
-
-    # Scattering function g_BSE (Equation 13, Section 2.1.3)
-    def g_BSE(_theta: float, _alpha: float, _beta: float) -> float:
-        print("_theta, _alpha, _beta:", _theta, _alpha, _beta)
-        """Equation 13 from the paper"""
-        # k: float = np.rad2deg(_theta) / 13  # Correct implementation as per the paper
-        # print("k: ", k)
-        diffusive_part: float = (eta_0(A) / pi) * np.cos(_alpha)
-        print(np.cos(_alpha))
-        print("diffusive_part:", diffusive_part)
-        # reflective_part: float = ((eta(_theta, A) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-        # if np.abs(reflective_part) < 1e-10:
-        # reflective_part = 0
-        # print("reflective_part:", reflective_part)
-
-        # Compute a weighted average BSE coefficient for the alloy
-        # reflective_part: float = ((compute_weighted_eta(_theta, Ti6Al4V) - eta_0(A)) / C(_theta)) * ((k + 1) / (2 * pi)) * (np.cos(_beta) ** k)
-
-        # print('g_BSE: ', diffusive_part + reflective_part)
-        return diffusive_part  #+ reflective_part
-
-
-    # Calculate the beam direction vector p_E
-    def calculate_p_E(_beam_x, _beam_y):
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        # Calculate the vector from the beam gun to the beam location
-        beam_vector = np.array([_beam_x * dx, _beam_y * dy, -detector_height], dtype=np.float64)
-        return normalize_vector(beam_vector)
-
-    def calculate_p_E1(_beam_x, _beam_y, _height):
-        """Calculate normalized beam direction vector from gun to surface point
-
-        Args:
-            _beam_x: Beam x-position relative to center (in cells)
-            _beam_y: Beam y-position relative to center (in cells)
-            _height: Height difference between gun and surface point (m)
-        """
-        print(f"calculate_p_E({_beam_x}, {_beam_y}):")
-        print("_height:", _height)
-
-        # Calculate vector components
-        x_component = _beam_x * dx  # Convert cells to meters
-        y_component = _beam_y * dy  # Convert cells to meters
-        z_component = _height       # Already in meters
-
-        # Create beam vector (from gun to surface point)
-        beam_vector = np.array([x_component, y_component, z_component], dtype=np.float64)
-
-        # Verify downward direction
-        if normalize_vector(beam_vector)[2] > 0:
-            raise ValueError("Beam vector pointing upward")
-
-        # Normalize and return
-        return normalize_vector(beam_vector)
-
-
-    # Calculate phi as the angle between p_E and the negative z-axis
-    def calculate_phi(_p_E) -> float:
-        z_axis = np.array([0.0, 0.0, -1.0], dtype=np.float64)
-
-        # Check if _p_E is approximately equal to the negative z-axis
-        '''if np.allclose(_p_E, z_axis, rtol=1e-8, atol=1e-8):
-            _phi_rad = 0.0
-        else:
-            # Calculate the angle between _p_E and the z-axis using arccos
-            _phi_rad = calculate_angle(_p_E, z_axis)'''
-        _phi_rad = calculate_angle(_p_E, z_axis)
-        print(f"calculate_phi({_p_E}, {_phi_rad}):")
-        return _phi_rad
-
-
-    def set_beam_movement(_x_center, _y_center, _x_offset, _y_offset, _movement_type):
-        if _movement_type == 'X':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            _beam_y_start = _y_center + _y_offset
-            _beam_y_end = _y_center + _y_offset + 1
-        elif _movement_type == 'Y':
-            _beam_x_start = _x_center + _x_offset
-            _beam_x_end = _x_center + _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        elif _movement_type == 'XY':
-            if _x_offset >= 0:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset - 1
-            else:
-                _beam_x_start = _x_center + _x_offset
-                _beam_x_end = _x_center - _x_offset + 1
-            if _y_offset >= 0:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset - 1
-            else:
-                _beam_y_start = _y_center + _y_offset
-                _beam_y_end = _y_center - _y_offset + 1
-        else:
-            raise ValueError("movement_type must be 'X' or 'Y' or 'XY'")
-
-        return _beam_x_start, _beam_x_end, _beam_y_start, _beam_y_end
-
-
-    # Initialize arrays to store electron counts
-    electrons_per_detector_per_step = np.zeros((x_num, y_num, len(detector_positions)))
-    total_electrons_per_step = np.zeros((x_num, y_num))
-
-    # Additional arrays to capture detailed information
-    electrons_per_detector_per_beam_profile = np.zeros(
-        (beam_profile.shape[0], beam_profile.shape[1], len(detector_positions)))
-    total_electrons_per_beam_profile = np.zeros((beam_profile.shape[0], beam_profile.shape[1]))
-
-    # Calculate the center indices
-    x_center = x_num // 2
-    y_center = y_num // 2
-    # print('x_center, y_center:', x_center, y_center)
-
-    x_offset: int = 0
-    y_offset: int = 0
-    movement_type: str = 'Y'
-
-    # Beam X-Movement
-    # beam_x_start, beam_x_end = x_center - x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center + y_offset, y_center + y_offset + 1
-
-    # Beam Y-Movement
-    # beam_x_start, beam_x_end = x_center + x_offset, x_center + x_offset + 1
-    # beam_y_start, beam_y_end = y_center - y_offset, y_center + y_offset + 1
-
-    # For X or Y Movement
-    beam_x_start, beam_x_end, beam_y_start, beam_y_end = set_beam_movement(
-        x_center, y_center, x_offset, y_offset, movement_type)
-
-    # Determine the step size based on the direction of movement
-    step_x = 1 if beam_x_start <= beam_x_end else -1
-    step_y = 1 if beam_y_start <= beam_y_end else -1
-
-    # print(beam_x_start, beam_x_end, beam_y_start, beam_y_end, step_x, step_y)
-
-
-    z_heights = np.zeros(beam_size)
-    fill_level_b = np.zeros(beam_size)
-    fill_level_a = np.zeros(beam_size)
-    normals = np.zeros((beam_size, 3))
-    normals_b = np.zeros((beam_size, 3))
-    normals_a = np.zeros((beam_size, 3))
-    cell_counter = 0
-
-
-    def normalize_vector(vector: np.ndarray) -> np.ndarray:
-        """Normalize a 3D vector."""
-        vector = vector.astype(np.float64)  # Convert to float64
-        norm = np.linalg.norm(vector)
-        if norm < 1e-18:  # Avoid division by zero
-            return vector  # Return original if nearly zero
-        return vector / norm  # Normalize
-
-
-    def calculate_angle(_v1: np.ndarray, _v2: np.ndarray) -> float:
-        """Calculate the angle between two normalized vectors."""
-        _v1 = normalize_vector(_v1)
-        _v2 = normalize_vector(_v2)
-
-        dot_product = np.clip(np.dot(_v1, _v2), -1.0, 1.0)
-
-        return np.arccos(dot_product)  # Return angle in radians
-
-
-    # Main simulation loop to iterate over the simulation domain
-    for beam_x in range(beam_x_start, beam_x_end, step_x):
-        for beam_y in range(beam_y_start, beam_y_end, step_y):
-            beam_loc = (beam_x, beam_y)
-            print(f"Beam center is at domain location: {beam_loc}")
-
-            # Calculate the beam direction vector p_E
-            # >> p_E = calculate_p_E(beam_x - x_center, beam_y - y_center)
-            # >> print('p_E: ', p_E)
-
-            # Calculate phi as the angle between p_E and the negative z-axis
-            # phi_rad = np.arctan2(np.sqrt(dx**2 + dy**2), w_d)
-            # >> phi_rad = calculate_phi(p_E)
-            # print(f"phi_rad: {phi_rad}")
-
-            # Reset counters for this beam location
-            total_electrons: int = 0
-            electrons_per_detector = np.zeros(len(detector_positions))
-
-            # Iterate over the beam profile
-            # for profile_x in [40]:
-            for profile_x in range(beam_profile.shape[0]):
-                # for profile_y in [79]:
-                for profile_y in range(beam_profile.shape[1]):
-                    print(f"Beam profile shape ({profile_x}, {profile_y})")
-                    domain_x = beam_loc[0] - beam_profile.shape[0] // 2 + profile_x
-                    domain_y = beam_loc[1] - beam_profile.shape[1] // 2 + profile_y
-                    print(f"Domain ({domain_x}, {domain_y})")
-
-                    # p_E = calculate_p_E(domain_x - x_center, domain_y - y_center)
-                    # print('p_E: ', p_E)
-                    # phi_rad = calculate_phi(p_E)
-
-                    if (0 <= domain_x < x_num) and (0 <= domain_y < y_num):
-                        if beam_profile[profile_x, profile_y] > threshold:
-                            print('finding interface_z_indices')
-                            # print('interface_z_indices:', interface_z)
-                            z_interp, z_height, fill_level_below, fill_level_above, normal_interp, normals_below, normals_above \
-                                = interpolate_fill_level_and_normal_new(fill_levels_smoothed, n_S, domain_y, domain_x)
-                            if z_interp is not None:
-                                z_heights[cell_counter] = z_height
-                                fill_level_b[cell_counter] = fill_level_below
-                                fill_level_a[cell_counter] = fill_level_above
-                                normals[cell_counter] = normal_interp
-                                normals_b[cell_counter] = normals_below
-                                normals_a[cell_counter] = normals_above
-                                cell_counter += 1
-                                n_S_local = normal_interp
-                                print('n_S_local:', n_S_local)
-                                if not np.allclose(np.linalg.norm(n_S_local), 1.0, atol=1e-5):
-                                    raise ValueError(f"n_S_local is not normalized, {np.linalg.norm(n_S_local)}")
-
-                                p_E = calculate_p_E1(domain_x - x_center, domain_y - y_center, -(detector_height-z_height))
-                                print('p_E: ', p_E)
-
-                                theta_local = calculate_angle(-p_E, n_S_local)
-                                print('theta_local:', theta_local)
-                                # if theta_local < 0.0 or theta_local > 0.011:
-                                # raise ValueError(f"theta_local value should be 0.0, found {theta_local}")
-                                # theta_local: 0.016
-
-                                scattering_point = np.array([
-                                    (domain_x - beam_x) * dx,
-                                    (domain_y - beam_y) * dy,
-                                    z_height
-                                ], dtype=np.float64)
-                                print(f'scattering_point ({domain_x}, {domain_y}, {z_interp}):', scattering_point)
-
-                                for det_idx, det_pos in enumerate(detector_positions):
-                                    # print(f"Detector {det_idx + 1} position: {det_pos}")
-                                    det_idx: int
-                                    # det_pos: np.ndarray[float]
-                                    print(f'det_pos[{det_idx + 1}]: {det_pos}')
-
-                                    # Calculate the vector from the scattering point to the detector
-                                    det_vec = det_pos - scattering_point
-                                    det_vec = normalize_vector(det_vec)
-                                    print(f'det_vec[{det_idx + 1}]: {det_vec}')
-                                    if not np.allclose(np.linalg.norm(det_vec), 1.0):
-                                        raise ValueError("det_vec is not normalized.")
-
-                                    # alpha = np.arccos(np.clip(np.dot(det_vec, n_S_local), -1.0, 1.0))
-                                    alpha = calculate_angle(det_vec, n_S_local)
-                                    print(f'alpha: {alpha}')
-
-                                    if alpha > np.pi / 2:
-                                        continue
-
-                                    p_E_reflected = p_E - 2.0 * np.dot(p_E, n_S_local) * n_S_local
-                                    # p_E_reflected = np.where(np.abs(p_E_reflected) < 1e-8, 0.0, p_E_reflected)
-                                    p_E_reflected = normalize_vector(p_E_reflected)
-                                    print(f'p_E_reflected: {p_E_reflected}')
-
-                                    # beta = np.arccos(np.clip(np.dot(p_E_reflected, det_vec), -1.0, 1.0))
-                                    beta = calculate_angle(p_E_reflected, det_vec)
-                                    print(f'beta: {beta}')
-
-                                    # Apply energy threshold
-                                    # electron_energy = N_0 * e * g_bse_value
-                                    # print('electron_energy: ', electron_energy)
-
-                                    # Calculate the initial energy of the primary electrons (E_0)
-                                    V_acc = 60e3  # Accelerating voltage in Volts
-                                    E_0 = V_acc * e  # Initial energy of primary electrons in joules
-
-                                    # Calculate the energy of the backscatter electrons
-                                    E_BSE = E_0 * eta(theta_local, A)
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # E_BSE = E_0 * compute_weighted_eta(theta_local, Ti6Al4V)
-                                    # print('E_BSE: ', E_BSE)
-
-                                    # Bifurcation logic for PE and SE
-                                    # if E_BSE > threshold_energy:
-                                    # Backscatter electrons (BSE)
-                                    # Compute scattering function g_bse_value
-                                    g_bse_value = g_BSE(theta_local, alpha, beta)
-                                    print(f'g_bse_value: {g_bse_value}')
-
-                                    # Ensure g_bse_value is non-negative
-                                    if g_bse_value <= 0:
-                                        raise ValueError(f"g_bse_value is {g_bse_value} (zero negative), check calculations.")
-
-                                    # Compute solid angle (Equation A1)
-                                    v1, v2, v3 = get_detector_vertices(det_pos)
-                                    normal = np.cross(v2 - v1, v3 - v1)
-                                    normal = normalize_vector(normal)
-
-                                    dOmega = compute_solid_angle(v1 - scattering_point,
-                                                                 v2 - scattering_point,
-                                                                 v3 - scattering_point)
-                                    print(f"Detector {det_idx + 1} solid angle: {dOmega}")
-
-                                    # Compute number of electrons collected by the detector (Equation A2)
-                                    # The beam ray stays on each grid point ij for the dwell time t_d and distributes N_0 PE into it
-                                    N_0 = beam_current * dwell_time / e  # Equation 3, Section 2.1.1
-                                    print("N_0:", N_0)
-
-                                    # There are N1 electrons in the chamber after the 1st scattering event
-                                    N_1 = N_0 * eta(theta_local, A)  # Equation 4, Section 2.1.1
-                                    # Compute a weighted average BSE coefficient for the alloy
-                                    # N_1 = N_0 * compute_weighted_eta(theta_local, Ti6Al4V)  # Equation 4, Section 2.1.1
-                                    print("N_1:", N_1)
-
-                                    # The number of electrons N_1_d collected by a general detector d
-                                    N_1_d = N_1 * np.sum(g_bse_value * dOmega)  # Equation 5, Section 2.1.1
-
-                                    # Accumulate electrons per detector for this beam profile point
-                                    electrons_per_detector_per_beam_profile[profile_x, profile_y, det_idx] += N_1_d
-
-                                    # Accumulate electrons per detector for this domain point
-                                    electrons_per_detector_per_step[beam_x, beam_y, det_idx] += N_1_d
-
-                                    # Add to total electrons for this beam profile point
-                                    total_electrons_per_beam_profile[profile_x, profile_y] += N_1_d
-
-                                    # Add to total electrons for this domain point
-                                    total_electrons_per_step[beam_x, beam_y] += N_1_d
-
-                                    # Accumulate electrons per detector
-                                    electrons_per_detector[det_idx] += N_1_d
-
-                                    # Add to total electrons
-                                    total_electrons += N_1_d
-
-                                    # print(f"After addition: electrons_per_detector[{det_idx + 1}]: {electrons_per_detector[det_idx]}")
-                            else:
-                                print(f"No interface found at ({domain_x}, {domain_y})")
-
-                    # Print detailed information for this beam profile point
-                    # print(f"Electrons per detector at this beam profile point ({profile_x}, {profile_y}): {electrons_per_detector_per_beam_profile[profile_x, profile_y]}")
-                    # print(f"Total electrons at this beam profile point ({profile_x}, {profile_y}): {total_electrons_per_beam_profile[profile_x, profile_y]}")
-
-            # Print detailed information for this domain point after iterating over the beam profile
-            # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-            # print(f"Total electrons at this domain point ({beam_x}, {beam_y}): {total_electrons_per_step[beam_x, beam_y]}")
-
-            # print("Shape of electrons_per_detector_per_step:", electrons_per_detector_per_step.shape)
-            # print("Number of lines:", len(lines))
-
-    # Compute final results
-    total_electrons_all_steps = np.sum(total_electrons_per_step)
-    total_electrons_per_detector = np.sum(electrons_per_detector_per_step, axis=(0, 1))
-
-    # Print final results
-    # print(f"Total electrons hitting all detectors: {total_electrons_all_steps}")
-    # for i, electrons in enumerate(total_electrons_per_detector):
-    # print(f"Total electrons hitting detector {i + 1}: {electrons}")
-
-    # for beam_x in range(beam_x_start, beam_x_end, step_x):
-    # for beam_y in range(beam_y_start, beam_y_end, step_y):
-    # print(f"Electrons per detector at this domain point ({beam_x}, {beam_y}): {electrons_per_detector_per_step[beam_x, beam_y]}")
-    # print('end simulation')
-    # print(electrons_per_detector_per_step)
-
-    # print("total_electrons:", total_electrons, "at timestep", current_timestep)
-    # print("electrons_per_det:", electrons_per_det, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc:", electrons_per_beam_loc, "at timestep", current_timestep)
-    # print("electrons_per_beam_loc_per_det:", electrons_per_beam_loc_per_det, "at timestep", current_timestep)
-    return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, normals, normals_b, normals_a
-
-def read_fill_levels(filepath):
-    """Read only the FillingFrac grid from VDB file"""
-    try:
-        # Try to read just the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        print(f"Grid dimensions: {dims}")
-
-        # Create array
-        fill_array = np.zeros(dims, dtype=np.float64)
-
-        # Copy data
-        grid.copyToArray(fill_array)
-
-        # Transpose to ray tracer's expected order (z, y, x)
-        fill_array = np.transpose(fill_array, (2, 1, 0))
-
-        # Create standardized array with 256 z cells
-        standardized_array = np.zeros((256, dims[1], dims[0]), dtype=np.float64)
-
-        # Copy data from original array to standardized array
-        standardized_array[:fill_array.shape[0], :, :] = fill_array
-
-        # Apply Gaussian smoothing
-        sigma = 1.0
-        standardized_array_smoothed = ndimage.gaussian_filter(standardized_array, sigma=sigma, mode='nearest', radius=1)
-
-        # Print values
-        x = 157
-        y = 196
-        print(f"\nFill levels at ({x}, {y}):")
-        print("z_index | fill_level")
-        print("-" * 20)
-
-        # Get fill levels
-        fill_levels = standardized_array_smoothed[:, y, x]
-        valid_levels = fill_levels[(fill_levels > 0) & (fill_levels < 1)]
-
-        if len(valid_levels) > 0:
-            for z, val in enumerate(fill_levels):
-                if 0 <= val <= 1:
-                    print(f"{z:7d} | {val:.6f}")
-        else:
-            print("No fill level found between 0 and 1")
-
-        return standardized_array_smoothed
-
-    except Exception as e:
-        print(f"Error reading {filepath}")
-        print(f"Error details: {str(e)}")
-        try:
-            metadata = vdb.readMetadata(filepath)
-            print("\nFile metadata:")
-            for key, value in metadata.items():
-                print(f"{key}: {value}")
-        except Exception as meta_e:
-            print(f"Error reading metadata: {str(meta_e)}")
-        return None
-
-def process_single_timestep(filepath, timestep):
-    """Process a single VDB file and run ray tracer"""
-    try:
-        # Read the FillingFrac grid
-        grid = vdb.read(filepath, "FillingFrac")
-
-        # Get dimensions
-        dims = grid.evalActiveVoxelDim()
-        x_num, y_num = dims[0], dims[1]
-        z_num = 256  # dims[2] # Fixed z dimension
-        print(f"Domain dimensions: {x_num} x {y_num} x {z_num}")
-
-        # Read fill levels
-        fill_levels = read_fill_levels(filepath)
-        if fill_levels is None:
-            raise ValueError("Could not read fill levels from VDB file")
-
-        if fill_levels is not None:
-            print("\nSuccessfully read fill levels")
-            print(f"Array shape: {np.shape(fill_levels)}")
-
-            try:
-                # Convert to numpy array if not already
-                fill_array = np.asarray(fill_levels)
-
-                # Get non-zero elements
-                non_zero = fill_array[fill_array > 0]
-
-                # Get interface cells (0 < fill_level < 1)
-                interface_cells = fill_array[(fill_array > 0) & (fill_array < 1)]
-
-                # Print statistics
-                if len(non_zero) > 0:
-                    print("\nNon-zero cells statistics:")
-                    print(f"Number of non-zero cells: {len(non_zero)}")
-                    print(f"Min non-zero value: {np.min(non_zero):.6f}")
-                    print(f"Max value: {np.max(fill_array):.6f}")
-
-                if len(interface_cells) > 0:
-                    print("\nInterface cells statistics:")
-                    interface_percentage = (len(interface_cells) * 100.0) / len(non_zero)
-                    print(f"Number of interface cells: {len(interface_cells)} ({interface_percentage:.3f}%)")
-                    print(f"Min interface value: {np.min(interface_cells):.6f}")
-                    print(f"Max interface value: {np.max(interface_cells):.6f}")
-                    print(f"Mean interface value: {np.mean(interface_cells):.6f}")
-                else:
-                    print("\nNo interface cells found (no fill levels between 0 and 1)")
-
-            except Exception as e:
-                print(f"Error processing array: {str(e)}")
-
-        # Run ray tracer
-        print(f"\nProcessing timestep {timestep}")
-        electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a = runRayTracer(
-            x_num,
-            y_num,
-            z_num,
-            # fill_levels.flatten(),
-            fill_levels,
-            timestep
-        )
-
-        return electrons_per_detector_per_step, z_heights, fill_level_b, fill_level_a, \
-            normals, normals_b, normals_a
-
-    except Exception as e:
-        print(f"Error processing timestep {timestep}: {str(e)}")
-        raise
-
-def main():
-    """Main function to process VDB files and run ray tracer"""
-    # Directory containing VDB files
-    directory = "/local/ca00xebo/softwares/KiSSAM/Markl-plate"
-
-    # Get sorted list of VDB files
-    vdb_files = []
-    for f in os.listdir(directory):
-        if f.startswith('geometry-00000') and f.endswith('.vdb'):
-            try:
-                # Extract timestep number from filename
-                timestep = int(f.split('-')[1].split('.')[0])
-                vdb_files.append((timestep, f))
-            except (IndexError, ValueError) as e:
-                print(f"Skipping file {f}: {str(e)}")
-
-    # Sort by timestep
-    vdb_files.sort()  # sort based on timestep number
-
-    total_timesteps = len(vdb_files)
-    print(f"Found {total_timesteps} VDB files to process")
-    electrons_across_timesteps = np.zeros((total_timesteps, 4))
-
-    # Process each timestep
-    _results = []
-    for timestep, vdb_file in enumerate(vdb_files):
-        filepath = os.path.join(directory, vdb_file[1])  # vdb_file[1] contains the filename
-        try:
-            print(f"\nProcessing file {vdb_file[1]} (timestep {timestep})")
-
-            # Process this timestep
-            _result = process_single_timestep(filepath, timestep)
-
-            # Debug electron counts
-            print("\nElectron count debug:")
-            print(f"Shape of electrons_per_detector_per_step: {_result[0].shape}")
-            print(f"Min value: {np.min(_result[0])}")
-            print(f"Max value: {np.max(_result[0])}")
-            print(f"Mean value: {np.mean(_result[0])}")
-
-            # Store electron counts with debugging
-            total_electrons_for_detectors = np.sum(_result[0], axis=(0, 1))
-            print(f"Total electrons before assignment: {total_electrons_for_detectors}")
-
-            electrons_across_timesteps[timestep] = total_electrons_for_detectors
-            print(f"Electrons for timestep {timestep}:")
-            print(f"Raw values: {electrons_across_timesteps[timestep]}")
-            print(f"Sum: {np.sum(electrons_across_timesteps[timestep])}")
-            print("electrons_across_timesteps:", electrons_across_timesteps)
-
-            _results.append(_result)
-            print(f"Completed timestep {timestep}")
-
-        except Exception as e:
-            print(f"Error processing timestep {timestep}: {str(e)}")
-            raise
-
-        # Final debug output
-    print("\nFinal electrons_across_timesteps:")
-    print(f"Shape: {electrons_across_timesteps.shape}")
-    print(f"Min value: {np.min(electrons_across_timesteps)}")
-    print(f"Max value: {np.max(electrons_across_timesteps)}")
-    print(f"Mean value: {np.mean(electrons_across_timesteps)}")
-
-    # Plot results
-    plot_detector_results1(electrons_across_timesteps)
-
-    return _results
-
-def plot_detector_results(electrons_per_detector_time):
-    """Plot electron counts for each detector across timesteps"""
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot for each detector
-    for detector in range(4):
-        plt.plot(timesteps, electrons_per_detector_time[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-
-    # Add value annotations
-    for detector in range(4):
-        for t, value in zip(timesteps, electrons_per_detector_time[:, detector]):
-            plt.annotate(f'{value:.2e}',
-                         (t, value),
-                         textcoords="offset points",
-                         xytext=(0,10),
-                         ha='center',
-                         rotation=45)
-
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time(VDB-multi).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create additional visualization - normalized values
-    plt.figure(figsize=(12, 8))
-    normalized_electrons = electrons_per_detector_time / np.max(electrons_per_detector_time)
-
-    for detector in range(4):
-        plt.plot(timesteps, normalized_electrons[:, detector],
-                 label=f'Detector {detector+1}',
-                 marker='o')
-
-    plt.title("Normalized Electrons Captured by Detectors Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Normalized Electron Count")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detector_electrons_over_time_normalized(VDB-multi).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-def plot_detector_results1(electrons_per_detector_time):
-    """
-    Plot electron counts for detector pairs across timesteps
-
-    Args:
-        electrons_per_detector_time: Array of shape (num_timesteps, 4) containing
-                                   electron counts for each detector at each timestep
-                                   :param electrons_per_detector_time:
-    """
-    # Create figure for detectors 1 and 2
-    plt.figure(figsize=(12, 8))
-    timesteps = range(len(electrons_per_detector_time))
-
-    # Plot detectors 1 and 2
-    plt.plot(timesteps, electrons_per_detector_time[:, 0],
-             label='Detector 1', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 1],
-             label='Detector 2', marker='s', linewidth=1, markevery=20)
-
-    plt.title("Electrons Captured by Detectors 1 and 2 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_1_2_over_time(VDB-multi).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-    # Create figure for detectors 3 and 4
-    plt.figure(figsize=(12, 8))
-
-    # Plot detectors 3 and 4
-    plt.plot(timesteps, electrons_per_detector_time[:, 2],
-             label='Detector 3', marker='o', linewidth=1, markevery=20)
-    plt.plot(timesteps, electrons_per_detector_time[:, 3],
-             label='Detector 4', marker='s', linewidth=1, markevery=20)
-
-    plt.title("Electrons Captured by Detectors 3 and 4 Over Time")
-    plt.xlabel("Timestep")
-    plt.ylabel("Number of Electrons")
-    plt.legend()
-    plt.grid(True)
-    plt.tight_layout()
-    plt.savefig("detectors_3_4_over_time(VDB-multi).png", dpi=300, bbox_inches='tight')
-    plt.close()
-
-if __name__ == "__main__":
-    results = main()
\ No newline at end of file
diff --git a/apps/showcases/FreeSurface/raytracerVDB.py b/apps/showcases/FreeSurface/raytracerVDB.py
index 6ff1874304a89a2d6b7eeab0239cf072fdf6a138..e10db3bff59e730fb7f9d69ed10d0b888bfd8dcf 100644
--- a/apps/showcases/FreeSurface/raytracerVDB.py
+++ b/apps/showcases/FreeSurface/raytracerVDB.py
@@ -1555,6 +1555,9 @@ def main():
     # Sort by timestep
     vdb_files.sort()  # sort based on timestep number
 
+    # Slice the first 120 files
+    # vdb_files = vdb_files[:120]
+
     total_timesteps = len(vdb_files)
     print(f"Found {total_timesteps} VDB files to process")
     electrons_across_timesteps = np.zeros((total_timesteps, 4))
diff --git a/apps/showcases/FreeSurface/testRayTracerFunctions.py b/apps/showcases/FreeSurface/testRayTracerFunctions.py
deleted file mode 100644
index 58afe1f4d71fd8ad90a846177180800f042d4019..0000000000000000000000000000000000000000
--- a/apps/showcases/FreeSurface/testRayTracerFunctions.py
+++ /dev/null
@@ -1,321 +0,0 @@
-import pyopenvdb as vdb
-import os
-from math import floor
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.colors as colors
-from matplotlib.animation import FuncAnimation
-from typing import Tuple, List
-import scipy.ndimage as ndimage
-
-def runRayTracer(x_num, y_num, z_num, fill_levels, current_timestep):
-
-    print("current_timestep", current_timestep)
-    # fill_levels = np.clip(fill_levels, 0, 1)
-    fill_levels = fill_levels.astype(np.float64)
-
-    # Define the tolerance
-    tolerance = 1e-8
-
-    # Check if any value in fill_levels is outside the [0, 1] range
-    out_of_range = np.logical_or(fill_levels < -tolerance, fill_levels > 1 + tolerance)
-    if np.any(out_of_range):
-        out_of_range_values = fill_levels[out_of_range]
-        out_of_range_indices = np.where(out_of_range)
-
-        for idx, value in zip(zip(*out_of_range_indices), out_of_range_values):
-            print(f"Index: {idx}, Value: {value}")
-
-        # raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    # Check for fill levels within the range [0, 1]
-    in_range = np.logical_and(fill_levels > 0, fill_levels < 1)
-    in_range_values = fill_levels[in_range]
-    in_range_indices = np.where(in_range)
-
-    # print("Fill level values within range (0 to 1):")
-    # for idx, value in zip(zip(*in_range_indices), in_range_values):
-    # if idx == (np.int64(309),):
-    # print(f"Index: {idx}, Value: {value}")
-
-    # Constants
-    pi: float = np.pi  # Mathematical constant π
-    e: float = 1.60217662e-19  # Elementary charge in Coulombs
-    beam_current: float = 3e-3  # Beam current in Amperes
-    dwell_time: float = 0.32e-6  # Dwell time in seconds
-    A: float = 21.5  # Approximation for Ti6Al4V
-    # Ti6Al4V = Ti6Al4V.create_Ti6Al4V(T)
-    # A: float = Ti6Al4V.atomic_number
-    # print('Ti6Al4V.elements: ', Ti6Al4V.elements)
-    # print('Ti6Al4V.composition: ', Ti6Al4V.composition)
-    # print('A: ', A)
-    dx: float = 3e-6  # Cell size in meters
-    dy: float = dx  # Cell size in meters
-    dz: float = dx  # Cell size in meters
-    threshold: float = 0  # Beam profile threshold
-    # The voltage at detector d can be computed using the transimpedance T (in units of V /A ) of a virtual amplifier in the context of the model
-    transimpedance: float = 1e5  # Transimpedance in Volts per Ampere
-    eV_threshold = 50  # Threshold energy in eV
-    threshold_energy = eV_threshold * e  # Threshold energy in joules
-
-    # Domain setup (Section 2.1.1)
-    # Define the 3D domain for the simulation
-    x_min, x_max = -0.5 * x_num * dx, 0.5 * x_num * dx  # -10 mm to 10 mm
-    # print('x_min, x_max:', x_min, x_max)
-    y_min, y_max = -0.5 * y_num * dy, 0.5 * y_num * dy  # -10 mm to 10 mm
-    # print('y_min, y_max:', y_min, y_max)
-    z_min, z_max = 0, z_num * dz
-    # print('z_min, z_max:', z_min, z_max)
-
-    fill_levels_reshaped = np.reshape(fill_levels, (z_num, y_num, x_num))
-    # fill_levels_reshaped = fill_levels
-    # print('fill_levels_reshaped:', fill_levels_reshaped)
-    out_of_range_fill_levels = np.logical_or(fill_levels_reshaped < -tolerance, fill_levels_reshaped > 1 + tolerance)
-    if np.any(out_of_range_fill_levels):
-        out_of_range_fill_level_values = fill_levels_reshaped[out_of_range_fill_levels]
-        out_of_range_fill_level_indices = np.where(out_of_range_fill_levels)
-
-        for idx, value in zip(zip(*out_of_range_fill_level_indices), out_of_range_fill_level_values):
-            print(f"Index: {idx}, Value: {value}")
-        raise ValueError(f"Fill level values must be between 0 and 1 inclusive (with tolerance of {tolerance}).")
-
-    sigma = 0.0
-    # Different sigma for each dimension
-    # sigma = [0.0, 1.0, 0.5]  # [z, y, x]
-    fill_levels_smoothed = ndimage.gaussian_filter(fill_levels_reshaped, sigma=sigma, mode='nearest', radius=1)
-
-    fill_levels_smoothed = np.clip(
-        fill_levels_smoothed,
-        a_min=np.float64(0.0),
-        a_max=np.float64(1.0)
-    )
-
-    # Check if values are still in bounds
-    if not (np.all(fill_levels_smoothed >= 0) and np.all(fill_levels_smoothed <= 1)):
-        raise ValueError("Smoothed fill levels out of bounds!")
-
-    def normalize_vector(vector: np.ndarray) -> np.ndarray:
-        """Normalize a 3D vector."""
-        vector = vector.astype(np.float64)  # Convert to float64
-        norm = np.linalg.norm(vector)
-        if norm < 1e-18:  # Avoid division by zero
-            return vector  # Return original if nearly zero
-        return vector / norm  # Normalize
-
-    def interpolate_normals_new(_normal_below: np.ndarray, _normal_above: np.ndarray, t: float) -> np.ndarray:
-        """Interpolate between two normals using SLERP"""
-        if not (0 <= t <= 1):
-            raise ValueError(f"Interpolation parameter t must be in [0,1], got {t}")
-
-        # Ensure normalized vectors
-        _normal_below = normalize_vector(_normal_below.astype(np.float64))
-        _normal_above = normalize_vector(_normal_above.astype(np.float64))
-
-        # Compute dot product
-        dot_product = np.clip(np.dot(_normal_below, _normal_above), -1.0, 1.0)
-
-        # Thresholds for special cases
-        PARALLEL_THRESHOLD = 0.9999
-        ANTIPARALLEL_THRESHOLD = -0.9999
-
-        # Case 1: Nearly parallel
-        if dot_product > PARALLEL_THRESHOLD:
-            return normalize_vector((1.0 - t) * _normal_below + t * _normal_above)
-
-        # Case 2: Nearly antiparallel
-        if dot_product < ANTIPARALLEL_THRESHOLD:
-            perp = np.array([1.0, 0.0, 0.0])
-            if abs(np.dot(_normal_below, perp)) > 0.9:
-                perp = np.array([0.0, 1.0, 0.0])
-            perp = normalize_vector(np.cross(_normal_below, perp))
-            angle = np.pi * t
-            return normalize_vector(_normal_below * np.cos(angle) + perp * np.sin(angle))
-
-        # Case 3: Standard SLERP
-        theta = np.arccos(dot_product)
-        sin_theta = np.sin(theta)
-
-        if abs(sin_theta) < 1e-10:
-            return normalize_vector(_normal_below)
-
-        scale0 = np.sin((1.0 - t) * theta) / sin_theta
-        scale1 = np.sin(t * theta) / sin_theta
-        return normalize_vector(scale0 * _normal_below + scale1 * _normal_above)
-
-
-    def interpolate_fill_level_and_normal_new(_fill_level, surface_normals, _y, _x):
-        """Find interface points and interpolate normals"""
-        if surface_normals.ndim != 4:
-            raise ValueError(f"Incorrect shape for surface normals: {surface_normals.ndim}")
-
-        _column = _fill_level[:, _y, _x]
-
-        # Constants
-        INTERFACE_THRESHOLD = 0.5
-        GRADIENT_THRESHOLD = 0.1
-        TOLERANCE = 1e-6
-
-        # Scan from top to bottom
-        for z in range(len(_column)-2, -1, -1):
-            current_fill = _column[z]
-            next_fill = _column[z + 1]
-
-            gradient = abs(next_fill - current_fill)
-            if gradient > GRADIENT_THRESHOLD:
-                # Use tolerance for interface detection
-                crosses_interface = (
-                    (current_fill >= INTERFACE_THRESHOLD - TOLERANCE and
-                     next_fill <= INTERFACE_THRESHOLD + TOLERANCE)
-                )
-
-                if crosses_interface:
-                    print(f"Found interface at z={z}")
-                    print(f"fill_level_below: {current_fill}")
-                    print(f"fill_level_above: {next_fill}")
-
-                    # Linear interpolation
-                    denominator = next_fill - current_fill
-                    if abs(denominator) < TOLERANCE:
-                        _z_interp = z + 0.5
-                        t_interface = 0.5
-                    else:
-                        # Handle numerical precision near 0.5
-                        if abs(current_fill - INTERFACE_THRESHOLD) < TOLERANCE:
-                            t_interface = 0.0
-                        elif abs(next_fill - INTERFACE_THRESHOLD) < TOLERANCE:
-                            t_interface = 1.0
-                        else:
-                            t_interface = (INTERFACE_THRESHOLD - current_fill) / denominator
-
-                        # Ensure t is in valid range
-                        if not (0 <= t_interface <= 1):
-                            # Try adjusting the threshold slightly
-                            adjusted_threshold = INTERFACE_THRESHOLD
-                            if t_interface < 0:
-                                adjusted_threshold -= TOLERANCE
-                            else:
-                                adjusted_threshold += TOLERANCE
-
-                            t_interface = (adjusted_threshold - current_fill) / denominator
-
-                            if not (0 <= t_interface <= 1):
-                                error_msg = (
-                                    f"\nInterpolation parameter t_interface={t_interface:.6f} is out of bounds [0,1]\n"
-                                    f"Debug information:\n"
-                                    f"  - z-index: {z}\n"
-                                    f"  - current_fill: {current_fill:.6f}\n"
-                                    f"  - next_fill: {next_fill:.6f}\n"
-                                    f"  - denominator: {denominator:.6f}\n"
-                                    f"  - INTERFACE_THRESHOLD: {INTERFACE_THRESHOLD}\n"
-                                    f"  - Position (y,x): ({_y}, {_x})\n"
-                                    f"  - Fill level values around interface:\n"
-                                )
-                                for i in range(max(0, z-2), min(len(_column), z+3)):
-                                    error_msg += f"    {i:3d} | {_column[i]:.6f}\n"
-                                raise ValueError(error_msg)
-
-                        _z_interp = z + t_interface
-
-                    _z_height = (_z_interp + 0.5) * dz
-
-                    # Get and interpolate normals
-                    _normal_below = surface_normals[z, _y, _x].astype(np.float64)
-                    _normal_above = surface_normals[z + 1, _y, _x].astype(np.float64)
-
-                    _normal_interp = interpolate_normals_new(_normal_below, _normal_above, t_interface)
-
-                    return _z_interp, _z_height, next_fill, current_fill, \
-                        _normal_interp, _normal_below, _normal_above
-
-        return None, None, None, None, None, None, None
-
-
-    def compute_surface_normals_sobel_zeros(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with default upward normal
-        _n_S_normalized = np.zeros_like(_n_S)
-        _n_S_normalized[..., 2] = 1.0
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    def compute_surface_normals_sobel_nans(_fill_levels: np.ndarray) -> np.ndarray:
-        """Compute surface normals using Sobel filter with NaN initialization"""
-        _fill_levels = _fill_levels.astype(np.float64)
-
-        if _fill_levels.ndim != 3:
-            raise ValueError("Input must be a 3D array")
-
-        TOLERANCE = {
-            'interface': 1e-10,
-            'norm': 1e-10
-        }
-
-        # Compute gradients without initial inversion
-        _grad_x = ndimage.sobel(_fill_levels, axis=2, output=np.float64, mode='nearest')
-        _grad_y = ndimage.sobel(_fill_levels, axis=1, output=np.float64, mode='nearest')
-        _grad_z = ndimage.sobel(_fill_levels, axis=0, output=np.float64, mode='nearest')
-
-        # Stack gradients
-        _n_S = np.stack([_grad_x, _grad_y, _grad_z], axis=-1)
-
-        # Create interface mask
-        interface_mask = np.logical_and(
-            _fill_levels > TOLERANCE['interface'],
-            _fill_levels < 1-TOLERANCE['interface']
-        )[..., np.newaxis]
-
-        # Ensure upward-pointing normals (from liquid to gas) after gradient calculation
-        mask = _n_S[..., 2] < 0
-        _n_S[mask] = -_n_S[mask]
-
-        # Normalize
-        norm = np.sqrt(np.sum(_n_S * _n_S, axis=-1, keepdims=True))
-        norm = np.maximum(norm, TOLERANCE['norm'])
-
-        # Initialize with NaN
-        _n_S_normalized = np.full_like(_n_S, np.nan)
-
-        # Apply normalization only at interface
-        _n_S_normalized = np.where(interface_mask, _n_S / norm, _n_S_normalized)
-
-        return _n_S_normalized
-
-
-    # Compute surface normals (Section 2.1.1)
-    n_S = compute_surface_normals_sobel_nans(fill_levels_smoothed)