Project: s41467-025-59105-6 | Function: calc_angle (python)

system: You are an expert Python programmer implementing algorithms from scientific papers.
Complete the masked function based on the algorithm description.
Follow Python best practices and maintain consistent code style.
Return **only** the complete Python function code.

user: Complete this Python function:

**DESCRIPTION:** Calculates angles between fluorine atoms and aromatic ring normal vectors for 19F NMR ring current analysis. Computes ring plane normal vectors, fluorine-to-ring center vectors, and angles between them to determine ring current effects.

**KEY TERMS:** {
  "angle_between": "angular contributions to ring current effects",
  "norm": "ring normal vector",
  "a1": "CF3-ring vector",
  "ang1": "angle θ"
}

**CONTEXT CODE:** 
# define functions
def unit_vector(vector): # returns the unit vector of a vector

    return vector / np.linalg.norm(vector)

def angle_between(v1, v2): # angle between vectors v1 and v2

    v1_u = v1
    v2_u = v2
    return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))

def calc_distance(u,res_19F,aromatic_res,aromatic_type,atom_names_19F,stride=1):
    """
    Calculates distances between fluorine atoms and aromatic ring centers for 19F NMR ring current analysis.

    This function implements the distance calculation component (r) of the geometric factor
    (1-3cos²θ)/r³ described in the paper "Rational design of 19F NMR labelling sites to probe
    protein structure and interactions" (Nature Communications, s41467-025-59105-6).

    The function computes center of mass for fluorine atoms and aromatic rings, then calculates
    inter-atomic distances for ring current effect determination in 19F NMR structural analysis.

    Paper reference: "The distances and angular orientation of the CF3 group relative to nearby
    aromatic sidechains were calculated using in-house Python scripts and the MDAnalysis package.
    We used the centre of mass of the CF3 group and the centre of mass of the neighbouring
    aromatic ring heavy atoms for these calculations"

    Args:
        u: MDAnalysis Universe object
        res_19F: Residue number containing 19F atoms
        aromatic_res: Residue number of aromatic ring
        aromatic_type: Type of aromatic residue (PHE, TYR, HIS, etc.)
        atom_names_19F: List of 19F atom names
        stride: Frame stride for trajectory analysis

    Returns:
        np.array: Distance values between CF3 center of mass and ring center of mass
    """
    # MASKED: Distance calculation for 19F NMR ring current analysis
    raise NotImplementedError(
        "This function is masked for testing purposes. "
        "Original function calculates distances between fluorine atoms and aromatic ring centers "
        "for 19F NMR ring current analysis using center of mass calculations and MDAnalysis. "
        "Implements the distance component (r) of the geometric factor (1-3cos²θ)/r³ "
        "described in Nature Communications article s41467-025-59105-6. "
        "Key operations: center of mass calculation, distance array computation, "
        "trajectory frame iteration for structural analysis."
    )

def calc_angle(universe,res_19F,aromatic_res,aromatic_type,atom_names_19F,stride=1):
    """
    Calculates angles between fluorine atoms and aromatic ring normal vectors for 19F NMR ring current analysis.

    This function implements the angular calculation component (θ) of the geometric factor
    (1-3cos²θ)/r³ described in the paper "Rational design of 19F NMR labelling sites to probe
    protein structure and interactions" (Nature Communications, s41467-025-59105-6).

    The function computes ring plane normal vectors, fluorine-to-ring center vectors, and angles
    between them to determine ring current effects in 19F NMR structural analysis.

    Paper reference: "We calculated averages and standard errors from three independent simulation
    replicates, discarding the first 200 ns of each replicate to allow for equilibration of the
    sidechain rotamers. We calculated a geometric factor quantifying both the distance and angular
    contributions to the expected ring current effects within one term, (1-3cos²θ)/r³"

    Args:
        universe: MDAnalysis Universe object
        res_19F: Residue number containing 19F atoms
        aromatic_res: Residue number of aromatic ring
        aromatic_type: Type of aromatic residue (PHE, TYR, HIS, HIE, HID, TRP)
        atom_names_19F: List of 19F atom names
        stride: Frame stride for trajectory analysis

    Returns:
        np.array: Angle values (θ) between CF3-ring vector and ring normal vector
    """
    # MASKED: Angular calculation for 19F NMR ring current analysis
    raise NotImplementedError(
        "This function is masked for testing purposes. "
        "Original function calculates angles between fluorine atoms and aromatic ring normal vectors "
        "for 19F NMR ring current analysis. Implements the angular component (θ) of the geometric "
        "factor (1-3cos²θ)/r³ described in Nature Communications article s41467-025-59105-6. "
        "Key operations: ring plane normal vector calculation using cross products, "
        "center of mass calculations, vector angle determination between CF3-ring and normal vectors, "
        "aromatic residue type-specific atom selections for different amino acids (PHE, TYR, HIS, etc.)."
    )


# load structures / trajectories

# run analysis
# define whether this is an MD traj analysis or PDB batch run
if __name__ == '__main__':
    if args.dir==None:
        if args.xtc==None:
            u = md.Universe(args.pdb)
            aromatic_residue = u.select_atoms('resid {}'.format(args.res2)).resnames[0]
        else:
            u = md.Universe(args.pdb,args.xtc)
            aromatic_residue = u.select_atoms('resid {}'.format(args.res2)).resnames[0]

        distances = calc_distance(u,args.res1,args.res2,aromatic_residue,list(args.atoms),stride=args.stride)
        angles = calc_angle(u,args.res1,args.res2,aromatic_residue,list(args.atoms),stride=args.stride)

        # save data
        np.savetxt(args.output+'/distances_{}F_{}{}.txt'.format(args.res1,args.res2,aromatic_residue),distances)
        np.savetxt(args.output+'/angles_{}F_{}{}.txt'.format(args.res1,args.res2,aromatic_residue),angles)

        if args.geomfactor=='True':
            distances_cm = distances*1e-8
            angles_rad = np.deg2rad(angles)
            geom = (1-3*np.cos(angles_rad)**2)/(distances_cm**3)

            # save data
            np.savetxt(args.output+'/geomfactors_{}F_{}{}.txt'.format(args.res1,args.res2,aromatic_residue),geom)

    else:
        pdb_files = []
        path=args.dir
        for root, dir, files in os.walk(path+'/'):
            for file in files:
                if '.pdb' in file:
                    pdb_files.append(file)
        pdb_files.sort() # sort pdb files

        # analyse all pdb pdb_files
        for FILE in pdb_files:
            name = FILE[:-4] # extract name by rmoving .pdb ending

            # load structure
            u = md.Universe(args.dir+str('/')+FILE)
            aromatic_residue = u.select_atoms('resid {}'.format(args.res2)).resnames[0]
            distances = calc_distance(u,args.res1,args.res2,aromatic_residue,list(args.atoms),stride=args.stride)
            angles = calc_angle(u,args.res1,args.res2,aromatic_residue,list(args.atoms),stride=args.stride)

            # save data
            np.savetxt(args.output+'/{}_distances_{}F_{}{}.txt'.format(name,args.res1,args.res2,aromatic_residue),distances)
            np.savetxt(args.output+'/{}_angles_{}F_{}{}.txt'.format(name,args.res1,args.res2,aromatic_residue),angles)

            if args.geomfactor=='True':
                distances_cm = distances*1e-8
                angles_rad = np.deg2rad(angles)
                geom = (1-3*np.cos(angles_rad)**2)/(distances_cm**3)

                # save data
                np.savetxt(args.output+'/{}_geomfactors_{}F_{}{}.txt'.format(name,args.res1,args.res2,aromatic_residue),geom)

    print('Done!')


Complete the function implementation replacing "raise NotImplementedError".