| import glob, pandas as pd, numpy as np |
| import matplotlib.pyplot as plt |
| from ase.io import read |
| from ase.neighborlist import natural_cutoffs, NeighborList |
| from matscipy.rings import ring_statistics |
| import re |
| from sklearn.linear_model import LinearRegression |
| import warnings |
| warnings.filterwarnings('ignore') |
|
|
| |
| |
| |
| def angle_between(v1, v2): |
| cos_theta = np.dot(v1, v2) |
| cos_theta = np.clip(cos_theta, -1.0, 1.0) |
| return np.degrees(np.arccos(cos_theta)) |
|
|
| def compute_angles(atoms, center_idx, neighbors): |
| if len(neighbors) < 2: |
| return 0.0, 0.0 |
| pos = atoms.positions |
| center_pos = pos[center_idx] |
| angles = [] |
| for i, j1 in enumerate(neighbors): |
| for j2 in neighbors[i+1:]: |
| vec1 = pos[j1] - center_pos |
| vec2 = pos[j2] - center_pos |
| if np.linalg.norm(vec1) > 0 and np.linalg.norm(vec2) > 0: |
| angles.append(angle_between(vec1/np.linalg.norm(vec1), |
| vec2/np.linalg.norm(vec2))) |
| return np.mean(angles) if angles else 0.0, np.std(angles) if angles else 0.0 |
|
|
|
|
| |
| |
| |
| global_rings_cache = {} |
|
|
| def get_global_rings(atoms): |
| key = f"{atoms.get_chemical_formula()}_{len(atoms)}" |
| if key not in global_rings_cache: |
| try: |
| all_rings = ring_statistics(atoms, cutoff=1.6, maxlength=8) |
| global_rings_cache[key] = { |
| 'total_rings_3_8': float(np.sum(all_rings[2:8])), |
| 'rings_4': float(all_rings[4]), |
| 'rings_5': float(all_rings[5]), |
| 'rings_6': float(all_rings[6]), |
| 'rings_7': float(all_rings[7]) |
| } |
| print(f"Cached rings for {key}") |
| except: |
| global_rings_cache[key] = { |
| 'total_rings_3_8': 0.0, |
| 'rings_4': 0.0, |
| 'rings_5': 0.0, |
| 'rings_6': 0.0, |
| 'rings_7': 0.0 |
| } |
| return global_rings_cache[key] |
|
|
|
|
| |
| |
| |
| def matscipy_ring_features(atoms, nearest_c, cutoff=1.6): |
| try: |
| global_rings = get_global_rings(atoms) |
|
|
| idxs = np.arange(len(atoms)) |
| dists = atoms.get_distances(nearest_c, idxs, mic=True) |
|
|
| mask = (dists < 5.0) & (dists > 1e-3) |
| local_atoms = atoms[mask] |
|
|
| if len(local_atoms) == 0: |
| raise RuntimeError("Empty local selection") |
|
|
| local_rings = ring_statistics(local_atoms, cutoff=cutoff, maxlength=8) |
|
|
| local_rings_3 = local_rings[3] if len(local_rings) > 3 else 0 |
| local_rings_4 = local_rings[4] if len(local_rings) > 4 else 0 |
| local_rings_5 = local_rings[5] if len(local_rings) > 5 else 0 |
| local_rings_6 = local_rings[6] if len(local_rings) > 6 else 0 |
| local_rings_7 = local_rings[7] if len(local_rings) > 7 else 0 |
|
|
| local_rings_3_8 = np.sum(local_rings[2:8]) |
| rings_per_atom = local_rings_3_8 / len(local_atoms) |
|
|
| ring_counts_3_8 = local_rings[2:8] |
| valid_rings = ring_counts_3_8 > 0 |
| ring_sizes = [size for size, exists in zip(range(3, 9), valid_rings) if exists] |
| smallest_ring = min(ring_sizes) if ring_sizes else 0 |
|
|
| return { |
| 'total_rings_3_8': global_rings['total_rings_3_8'], |
| 'global_rings_4': global_rings['rings_4'], |
| 'global_rings_5': global_rings['rings_5'], |
| 'global_rings_6': global_rings['rings_6'], |
| 'global_rings_7': global_rings['rings_7'], |
|
|
| 'local_rings_3_8': float(local_rings_3_8), |
| 'local_rings_3': float(local_rings_3), |
| 'local_rings_4': float(local_rings_4), |
| 'local_rings_5': float(local_rings_5), |
| 'local_rings_6': float(local_rings_6), |
| 'local_rings_7': float(local_rings_7), |
| 'local_hexagons': float(local_rings_6), |
| 'rings_per_atom': float(rings_per_atom), |
| 'smallest_ring': float(smallest_ring), |
| } |
|
|
| except Exception as e: |
| print(f"Ring stats error: {e}") |
| zeros = {k: 0.0 for k in [ |
| 'total_rings_3_8','global_rings_4','global_rings_5', |
| 'global_rings_6','global_rings_7','local_rings_3_8', |
| 'local_rings_3','local_rings_4','local_rings_5', |
| 'local_rings_6','local_rings_7','local_hexagons', |
| 'rings_per_atom','smallest_ring' |
| ]} |
| return zeros |
|
|
|
|
| |
| |
| |
| def advanced_features(atoms, nearest_c, c_neighbors, nl): |
| pos = atoms.positions |
| center_pos = pos[nearest_c] |
|
|
| neighbor_dists_raw = np.linalg.norm(pos[c_neighbors] - center_pos, axis=1) |
| valid_mask = (neighbor_dists_raw > 0.8) & (neighbor_dists_raw < 2.5) |
| neighbor_dists = neighbor_dists_raw[valid_mask] |
| valid_neighbors = [c_neighbors[i] for i in np.where(valid_mask)[0]] |
|
|
| if len(neighbor_dists) < 2: |
| neighbor_dists = np.array([1.42]) |
|
|
| curvature = np.std(neighbor_dists) / max(np.mean(neighbor_dists), 1.2) |
|
|
| planarity = 0.0 |
| if len(valid_neighbors) >= 3: |
| try: |
| X = pos[valid_neighbors, :2] |
| y = pos[valid_neighbors, 2] |
| reg = LinearRegression().fit(X, y) |
| planarity = np.mean((y - reg.predict(X))**2) |
| except: |
| pass |
|
|
| bond_var = np.var(np.clip(neighbor_dists, 1.0, 2.0)) |
|
|
| angles_local = [] |
| for i in range(len(valid_neighbors)): |
| for j in range(i+1, len(valid_neighbors)): |
| vec1 = pos[valid_neighbors[i]] - center_pos |
| vec2 = pos[valid_neighbors[j]] - center_pos |
| if np.linalg.norm(vec1) > 0 and np.linalg.norm(vec2) > 0: |
| angle = angle_between(vec1/np.linalg.norm(vec1), |
| vec2/np.linalg.norm(vec2)) |
| angles_local.append(angle) |
|
|
| q6 = np.abs(np.mean(np.exp(1j * np.radians(angles_local) * 6))) if angles_local else 0.0 |
| q6 = min(q6, 0.95) |
|
|
| z_mean = np.mean(pos[:,2]) |
| surface_cn = sum(1 for nb in valid_neighbors if abs(pos[nb,2] - z_mean) < 1.0) |
|
|
| neighbor_cn_var = 0.0 |
| try: |
| neighbor_cns = [len(nl.get_neighbors(nb)[0]) for nb in valid_neighbors] |
| neighbor_cn_var = np.var(neighbor_cns) |
| except: |
| pass |
|
|
| h_idx = next((i for i,s in enumerate(atoms) if s.symbol=='H'), None) |
| h_bond_dist = atoms.get_distance(nearest_c, h_idx) if h_idx is not None else 0.0 |
|
|
| return { |
| 'curvature': float(curvature), |
| 'planarity': float(planarity), |
| 'bond_var': float(bond_var), |
| 'q6_order': float(q6), |
| 'surface_cn': float(surface_cn), |
| 'neighbor_cn_var': float(neighbor_cn_var), |
| 'h_bond_dist': float(h_bond_dist) |
| } |
|
|
|
|
| |
| |
| |
| def extract_id(filename): |
| match = re.search(r'POSCAR_(\d+)', filename) |
| return int(match.group(1)) if match else None |
|
|
|
|
| |
| |
| |
| def compute_multishell_densities(atoms, center_idx): |
| idxs = np.arange(len(atoms)) |
| d = atoms.get_distances(center_idx, idxs, mic=True) |
|
|
| result = {} |
| for R in (2.0, 3.0, 5.0): |
| mask = (d < R) & (d > 1e-3) |
| result[R] = int(np.sum(mask)) |
|
|
| return result |
|
|
|
|
| |
| |
| |
| def local_features(atoms): |
| try: |
| if len(atoms) < 10: |
| return None |
|
|
| h_indices = [i for i,s in enumerate(atoms) if s.symbol=='H'] |
| if not h_indices: |
| return None |
| h_idx = h_indices[0] |
|
|
| c_indices = [i for i,s in enumerate(atoms) if s.symbol=='C'] |
| if not c_indices: |
| return None |
|
|
| dists = atoms.get_distances(h_idx, c_indices, mic=True) |
| nearest_c = c_indices[np.argmin(dists)] |
|
|
| cutoffs = natural_cutoffs(atoms) |
| nl = NeighborList(cutoffs, self_interaction=False, bothways=True) |
| nl.update(atoms) |
|
|
| neighbors = nl.get_neighbors(nearest_c)[0] |
| c_neighbors = [i for i in neighbors if atoms[i].symbol == 'C'] |
|
|
| cn = len(c_neighbors) |
| theta_mean, theta_std = compute_angles(atoms, nearest_c, c_neighbors) |
|
|
| idxs = np.arange(len(atoms)) |
| d_all = atoms.get_distances(nearest_c, idxs, mic=True) |
| density_mask = (d_all < 5.0) & (d_all > 1e-3) |
| local_density = np.sum(density_mask) |
|
|
| |
| shell = compute_multishell_densities(atoms, nearest_c) |
|
|
| height = abs(atoms[nearest_c].position[2] - |
| np.mean(atoms.positions[c_neighbors, 2])) if cn >= 3 else 0.0 |
|
|
| geo_feats = advanced_features(atoms, nearest_c, c_neighbors, nl) |
| ring_feats = matscipy_ring_features(atoms, nearest_c) |
|
|
| return { |
| 'cn': float(cn), |
| 'theta_mean': theta_mean, |
| 'theta_std': theta_std, |
| 'local_density': float(local_density), |
| 'density_2A': float(shell[2.0]), |
| 'density_3A': float(shell[3.0]), |
| 'density_5A': float(shell[5.0]), |
| 'height': height, |
| **geo_feats, |
| **ring_feats |
| } |
|
|
| except Exception as e: |
| print(f"Feature error: {e}") |
| return None |
|
|
|
|
| |
| |
| |
| print("=== EXTENDED RING + GEOMETRY FEATURES ===") |
| features = [] |
| poscar_files = sorted(glob.glob('POSCAR_*')) |
|
|
| for poscar in poscar_files: |
| print(f"Processing {poscar}...", end=" ") |
| try: |
| atoms = read(poscar) |
| feats = local_features(atoms) |
| if feats: |
| feats['filename'] = poscar |
| feats['id'] = extract_id(poscar) |
| features.append(feats) |
| print("✓") |
| else: |
| print("✗") |
| except Exception as e: |
| print(f"✗ {e}") |
|
|
| df_final = pd.DataFrame(features) |
|
|
|
|
| |
| |
| |
| energy_data = [] |
| try: |
| with open('energy_pairs.txt', 'r') as f: |
| for line in f: |
| if line.startswith('#'): |
| continue |
| parts = line.split() |
| if len(parts) >= 5: |
| energy_data.append({'id': int(parts[0]), 'ΔG(eV)': float(parts[4])}) |
| except: |
| pass |
|
|
| if energy_data: |
| df_final = df_final.merge(pd.DataFrame(energy_data), on='id', how='left') |
|
|
| df_final.to_csv('HER_sites_full.csv', index=False) |
|
|
| if 'ΔG(eV)' in df_final.columns: |
| df_clean = df_final.dropna(subset=['ΔG(eV)']) |
| df_clean.to_csv('HER_sites_cleaned.csv', index=False) |
| print(f"✓ Full: {len(df_final)} → Cleaned: {len(df_clean)}") |
|
|
| else: |
| print(f"Structural only: {len(df_final)} sites") |
|
|
|
|
| print("\nReady.") |
|
|
|
|