Skip to content

Commit 2d2da43

Browse files
sandipdeSandip De
andauthored
feat: add SOAP-based symmetry-equivalent site detection (#10)
* feat: add SOAP-based symmetry-equivalent site detection Add a second method for identifying symmetry non-equivalent adsorption sites using SOAP (Smooth Overlap of Atomic Positions) descriptors from the dscribe package with hierarchical clustering, as an alternative to ASE's SymmetryEquivalenceCheck. Core implementation (autoadsorbate/utils.py): - _compute_soap_site_vectors(): compute SOAP descriptors for candidate sites - _soap_similarity_matrix(): pairwise cosine similarity matrix - _filter_unique_sites_by_soap(): cluster sites via hierarchical clustering or greedy merging, with configurable similarity_threshold (default 0.99) Unified API (autoadsorbate/autoadsorbate.py): - Surface.get_nonequivalent_sites() accepts method="ase"|"soap" - Surface.sym_reduce() accepts method="ase"|"soap" - New methods: get_soap_similarity_matrix(), get_nonequivalent_sites_soap(), soap_reduce() Tests (tests/test_all.py): - 13 tests covering SOAP similarity matrix, hcluster/greedy modes, soap_reduce, method switching, ASE vs SOAP comparison on Ni/Ru slab, timing benchmarks Documentation (README.md): - SOAP method usage section with API examples - Threshold tuning guide - Dedicated ASE vs SOAP benchmark section with: - Method comparison table (ASE: 16 sites/8.4s vs SOAP: 8 sites/0.016s) - Detailed breakdown by site type (8 SOAP clusters) - Site comparison plot (site_comparison.png) - SOAP distance histogram (soap_histogram.png) - Discussion of why SOAP outperforms ASE on broken-symmetry surfaces Scripts: - scripts/compare_sites.py: detailed ASE vs SOAP comparison - scripts/plot_sites.py: site visualization with color-coded SOAP clusters - scripts/site_comparison.png: generated comparison image - scripts/soap_histogram.png: SOAP distance distribution plot On Ni(111) 3x3 slab with one Ru dopant: - ASE finds 16 unique sites in ~8.4 s - SOAP finds 8 unique sites in ~0.016 s (~525x speedup) * update version * updated dependency --------- Co-authored-by: Sandip De <desa@basfad.basf.net>
1 parent 96680eb commit 2d2da43

11 files changed

Lines changed: 888 additions & 54 deletions

File tree

‎.gitignore‎

272 Bytes
Binary file not shown.

‎README.md‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,34 @@ plt.show()
401401

402402

403403

404-
We can reduce the complete list of sites based on symmetry (```ase.utils.structure_comparator.SymmetryEquivalenceCheck```):
404+
We can reduce the complete list of sites based on symmetry. Two methods are available:
405405

406+
1. **ASE crystallographic symmetry** (default) – uses `ase.utils.structure_comparator.SymmetryEquivalenceCheck`:
407+
408+
```python
409+
s.sym_reduce() # equivalent to s.sym_reduce(method='ase')
410+
s.site_df
411+
```
412+
413+
2. **SOAP descriptor similarity** – uses SOAP descriptors ([DScribe](https://singroup.github.io/dscribe/)) and hierarchical clustering, robust for disordered / low-symmetry surfaces:
414+
415+
```python
416+
# Requires: pip install dscribe scikit-learn scipy
417+
s.sym_reduce(method='soap') # default similarity_threshold=0.99
418+
s.site_df
419+
```
420+
421+
The SOAP method can be further tuned:
422+
```python
423+
s.sym_reduce(
424+
method='soap',
425+
similarity_threshold=0.95, # lower = more aggressive merging
426+
soap_params={'r_cut': 6.0, 'n_max': 8, 'l_max': 6, 'sigma': 0.1},
427+
soap_cluster_method='hcluster', # or 'greedy'
428+
)
429+
```
430+
431+
Using the default (ASE) method:
406432

407433
```python
408434
s.sym_reduce()
@@ -496,6 +522,89 @@ plot_atoms(s.view_surface(return_atoms=True))
496522

497523

498524

525+
## Benchmark: ASE vs SOAP symmetry reduction
526+
527+
The two symmetry-reduction methods differ fundamentally in how they define "equivalent":
528+
529+
| Aspect | ASE (`SymmetryEquivalenceCheck`) | SOAP (dscribe + hierarchical clustering) |
530+
|---|---|---|
531+
| **Equivalence criterion** | Strict crystallographic space-group symmetry | Cosine similarity of local SOAP descriptors |
532+
| **Adjustable threshold** | No — binary match/no-match | Yes — continuous `similarity_threshold` |
533+
| **Scaling** | O(n²) pairwise structure comparisons | O(n) SOAP evaluations + O(n²) similarity matrix |
534+
| **Robustness to disorder** | Breaks down when symmetry is broken | Groups by local chemical environment |
535+
536+
### Ni(111) 3×3 slab with 1 Ni → Ru substitution
537+
538+
To illustrate the difference, we replace one surface Ni with Ru on a Ni(111) 3×3 slab (54 candidate adsorption sites) and compare both methods:
539+
540+
```python
541+
import copy, time
542+
from ase.build import fcc111
543+
from autoadsorbate import Surface
544+
545+
slab = fcc111("Ni", (3, 3, 3), periodic=True, vacuum=10)
546+
top_z = slab.positions[:, 2].max()
547+
for atom in slab:
548+
if abs(atom.position[2] - top_z) < 0.1:
549+
atom.symbol = "Ru"
550+
break
551+
552+
s = Surface(slab)
553+
s_ase = copy.deepcopy(s)
554+
s_soap = copy.deepcopy(s)
555+
556+
s_ase.sym_reduce(method="ase")
557+
s_soap.sym_reduce(method="soap", similarity_threshold=0.99)
558+
```
559+
560+
#### Timing results
561+
562+
| Method | Unique sites | Time | Speedup |
563+
|---|---:|---:|---:|
564+
| ASE `SymmetryEquivalenceCheck` | 16 | 8.4 s | 1× |
565+
| SOAP + hierarchical clustering | 8 | 0.016 s | **~525×** |
566+
567+
#### Detailed breakdown by site type
568+
569+
| SOAP Cluster | Site type | Formula | Total sites | ASE reps | SOAP rep | Interpretation |
570+
|---|---|---|---:|---:|---:|---|
571+
| 8 | atop | {Ru: 1} | 1 | 1 | 1 | Unique Ru atop — both agree |
572+
| 1 | atop | {Ni: 1} | 8 | 3 | 1 | ASE splits by distance-to-Ru; SOAP merges (all Ni atop) |
573+
| 2 | bridge | {Ru:1, Ni:1} | 6 | 1 | 1 | Ru-Ni bridges — both agree |
574+
| 3 | bridge | {Ni: 2} | 21 | 5 | 1 | ASE splits into 5; SOAP merges all Ni-Ni bridges |
575+
| 4 | hollow | {Ru:1, Ni:2} | 3 | 1 | 1 | Near-Ru hollows — both agree |
576+
| 6 | hollow | {Ru:1, Ni:2} | 3 | 1 | 1 | Far-Ru hollows — both agree |
577+
| 5 | hollow | {Ni: 3} | 6 | 2 | 1 | ASE splits into 2; SOAP merges |
578+
| 7 | hollow | {Ni: 3} | 6 | 2 | 1 | ASE splits into 2; SOAP merges |
579+
580+
The SOAP method captures the **physically meaningful site diversity** (8 distinct local environments) while the ASE method finds 16 sites that differ only by their distance from the Ru dopant within an otherwise identical coordination shell.
581+
582+
#### Site map
583+
584+
![Site comparison](README_files/site_comparison.png)
585+
586+
*Left: 16 ASE representatives. Right: 8 SOAP representatives. Coloured by SOAP cluster; marker shape = site type (○ atop, □ bridge, △ hollow). Grey dots = all 54 candidate sites.*
587+
588+
#### SOAP distance histogram
589+
590+
```python
591+
sim = s.get_soap_similarity_matrix()
592+
dist = 1.0 - sim
593+
upper = dist[np.triu_indices_from(dist, k=1)]
594+
595+
fig, ax = plt.subplots(figsize=(7, 3.5))
596+
ax.hist(upper, bins=60, edgecolor="black", linewidth=0.4, color="#4C72B0")
597+
ax.axvline(0.01, color="red", ls="--", lw=1.5,
598+
label="threshold = 0.01\n(similarity = 0.99)")
599+
ax.set_xlabel("SOAP distance (1 − cosine similarity)")
600+
ax.set_ylabel("Number of site pairs")
601+
ax.legend(fontsize=9)
602+
```
603+
604+
![SOAP distance histogram](README_files/soap_histogram.png)
605+
606+
The histogram shows a clear separation between intra-cluster pairs (distance ≈ 0) and inter-cluster pairs, confirming that the 0.99 similarity threshold sits in the natural gap between equivalent and non-equivalent site pairs.
607+
499608
## Making surogate SMILES automatically
500609

501610
Simple methods of brute force SMILES enumeration are implemented as well. For example, only using a few lines of code we can initialize multiple conformers of all reaction intermediates in the nitrogen hydrogenation reaction. A template of the required information can be found here:
@@ -678,7 +787,8 @@ from autoadsorbate import Surface, Fragment
678787

679788
slab = fcc211(symbol = 'Cu', size=(6,3,3), vacuum=10) # any ase.Atoms object
680789
s=Surface(slab, touch_sphere_size=2.7) # finding all surface atoms
681-
s.sym_reduce() # keeping only non-identical sites
790+
s.sym_reduce() # keeping only non-identical sites (default: method='ase')
791+
# s.sym_reduce(method='soap') # alternative: SOAP-descriptor based reduction
682792

683793
fragments = [
684794
Fragment('S1S[OH+]CC(N)[OH+]1', to_initialize=20), # For each *SMILES we can request a differnet number of conformers

‎README_files/site_comparison.png‎

257 KB
Loading

‎README_files/soap_histogram.png‎

29.8 KB
Loading

‎autoadsorbate/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__author__ = """Fakoe Edvin"""
44
__email__ = "edvinfako@gmail.com"
5-
__version__ = "0.2.5"
5+
__version__ = "0.2.6"
66

77
from autoadsorbate.autoadsorbate import Fragment, Surface
88
from autoadsorbate.Smile import get_marked_smiles

‎autoadsorbate/autoadsorbate.py‎

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
from .utils import (
1919
get_sorted_by_snap_dist,
2020
make_site_info_writable,
21+
_compute_soap_site_vectors,
22+
_soap_similarity_matrix,
23+
_filter_unique_sites_by_soap,
2124
)
2225

2326
from .Particle import get_shrinkwrap_particle_ads_sites
@@ -393,13 +396,53 @@ def compare_sites(self, site_index1: int, site_index2: int, **kwargs) -> bool:
393396

394397
return SEC.compare(self.atoms + site1, self.atoms + site2)
395398

396-
def get_nonequivalent_sites(self, **kwargs) -> List[int]:
397-
"""
398-
Returns a list of indices for nonequivalent sites.
399+
def get_nonequivalent_sites(
400+
self,
401+
method: str = "ase",
402+
similarity_threshold: float = 0.99,
403+
soap_params: dict = None,
404+
soap_cluster_method: str = "hcluster",
405+
**kwargs,
406+
) -> List[int]:
407+
"""Return a list of indices for nonequivalent sites.
408+
409+
Args:
410+
method (str, optional): Algorithm used to determine equivalence.
411+
``"ase"`` (default) – ASE's crystallographic
412+
``SymmetryEquivalenceCheck`` (pairwise comparison, exact
413+
space-group symmetry). ``"soap"`` – SOAP descriptor
414+
similarity with hierarchical clustering (robust for
415+
disordered / low-symmetry surfaces). Any extra
416+
``**kwargs`` are forwarded to
417+
``SymmetryEquivalenceCheck`` when *method="ase"*.
418+
similarity_threshold (float, optional): Cosine-similarity
419+
threshold (only used when *method="soap"*). Defaults
420+
to ``0.99``.
421+
soap_params (dict, optional): SOAP hyper-parameters forwarded
422+
to ``dscribe.descriptors.SOAP`` (only used when
423+
*method="soap"*).
424+
soap_cluster_method (str, optional): ``"hcluster"`` or
425+
``"greedy"`` (only used when *method="soap"*).
399426
400427
Returns:
401428
List[int]: A list of indices for nonequivalent sites.
402429
"""
430+
if method == "ase":
431+
return self._get_nonequivalent_sites_ase(**kwargs)
432+
elif method == "soap":
433+
return self.get_nonequivalent_sites_soap(
434+
similarity_threshold=similarity_threshold,
435+
soap_params=soap_params,
436+
method=soap_cluster_method,
437+
)
438+
else:
439+
raise ValueError(
440+
f"Unknown method '{method}'. Choose 'ase' or 'soap'."
441+
)
442+
443+
def _get_nonequivalent_sites_ase(self, **kwargs) -> List[int]:
444+
"""Return nonequivalent-site indices using ASE's
445+
``SymmetryEquivalenceCheck`` (legacy behaviour)."""
403446
original = []
404447
i_s = self.site_df.index.values
405448
matches = np.array([False for _ in i_s])
@@ -414,11 +457,142 @@ def get_nonequivalent_sites(self, **kwargs) -> List[int]:
414457
break
415458
return original
416459

417-
def sym_reduce(self, **kwargs):
460+
def sym_reduce(
461+
self,
462+
method: str = "ase",
463+
similarity_threshold: float = 0.99,
464+
soap_params: dict = None,
465+
soap_cluster_method: str = "hcluster",
466+
**kwargs,
467+
):
468+
"""Reduce the site DataFrame to nonequivalent sites.
469+
470+
Args:
471+
method (str, optional): ``"ase"`` (default) for
472+
crystallographic symmetry, ``"soap"`` for SOAP-descriptor
473+
based clustering. See :meth:`get_nonequivalent_sites`
474+
for full parameter descriptions.
475+
similarity_threshold (float, optional): SOAP cosine-similarity
476+
threshold (only when *method="soap"*).
477+
soap_params (dict, optional): SOAP hyper-parameters (only when
478+
*method="soap"*).
479+
soap_cluster_method (str, optional): ``"hcluster"`` or
480+
``"greedy"`` (only when *method="soap"*).
481+
**kwargs: Extra arguments forwarded to
482+
``SymmetryEquivalenceCheck`` when *method="ase"*.
418483
"""
419-
Reduces the site DataFrame to nonequivalent sites.
484+
include = self.get_nonequivalent_sites(
485+
method=method,
486+
similarity_threshold=similarity_threshold,
487+
soap_params=soap_params,
488+
soap_cluster_method=soap_cluster_method,
489+
**kwargs,
490+
)
491+
include_filter = [i in include for i in self.site_df.index.values]
492+
self.site_df = self.site_df[include_filter]
493+
self.site_dict = self.site_df.to_dict(orient="list")
494+
495+
# ------------------------------------------------------------------
496+
# SOAP-descriptor based symmetry reduction
497+
# ------------------------------------------------------------------
498+
499+
def get_soap_similarity_matrix(
500+
self,
501+
soap_params: dict = None,
502+
probe_element: str = "X",
503+
) -> np.ndarray:
504+
"""Return the pairwise cosine-similarity matrix for all sites using
505+
SOAP descriptors (via *dscribe*).
506+
507+
A ghost probe atom is placed at each site position and SOAP descriptors
508+
are evaluated within the periodic slab environment.
509+
510+
Args:
511+
soap_params (dict, optional): SOAP hyper-parameters forwarded to
512+
``dscribe.descriptors.SOAP``. Defaults to
513+
``{r_cut: 5.0, n_max: 8, l_max: 6, sigma: 0.1}``.
514+
probe_element (str, optional): Element for the probe atom
515+
(must not be present in the slab). Defaults to ``"X"``.
516+
517+
Returns:
518+
np.ndarray: Symmetric similarity matrix of shape
519+
``(n_sites, n_sites)`` with values in ``[-1, 1]``.
520+
521+
Requires:
522+
``dscribe`` (``pip install dscribe``).
420523
"""
421-
include = self.get_nonequivalent_sites(**kwargs)
524+
soap_vectors = _compute_soap_site_vectors(
525+
self.atoms, self.site_df,
526+
soap_params=soap_params,
527+
probe_element=probe_element,
528+
)
529+
return _soap_similarity_matrix(soap_vectors)
530+
531+
def get_nonequivalent_sites_soap(
532+
self,
533+
similarity_threshold: float = 0.99,
534+
soap_params: dict = None,
535+
method: str = "hcluster",
536+
) -> List[int]:
537+
"""Return indices of non-equivalent sites determined by SOAP
538+
descriptor similarity and hierarchical clustering.
539+
540+
This is an alternative to :meth:`get_nonequivalent_sites` which relies
541+
on ASE's crystallographic ``SymmetryEquivalenceCheck``. The SOAP
542+
approach is more robust for disordered or low-symmetry surfaces and
543+
allows continuous tuning of the similarity threshold.
544+
545+
Args:
546+
similarity_threshold (float, optional): Cosine similarity above
547+
which two sites are deemed equivalent. Defaults to ``0.99``.
548+
soap_params (dict, optional): SOAP hyper-parameters forwarded to
549+
``dscribe.descriptors.SOAP``. Defaults to
550+
``{r_cut: 5.0, n_max: 8, l_max: 6, sigma: 0.1}``.
551+
method (str, optional): Clustering algorithm. ``"hcluster"``
552+
(default) uses agglomerative hierarchical clustering
553+
(``scipy.cluster.hierarchy``); ``"greedy"`` uses the legacy
554+
greedy merging approach.
555+
556+
Returns:
557+
List[int]: DataFrame index labels of one representative per
558+
equivalence class.
559+
560+
Requires:
561+
``dscribe``, ``scikit-learn``, and (for *method="hcluster"*)
562+
``scipy``.
563+
"""
564+
reduced = _filter_unique_sites_by_soap(
565+
slab=self.atoms,
566+
site_df=self.site_df,
567+
soap_params=soap_params,
568+
similarity_threshold=similarity_threshold,
569+
method=method,
570+
)
571+
return list(reduced.index)
572+
573+
def soap_reduce(
574+
self,
575+
similarity_threshold: float = 0.99,
576+
soap_params: dict = None,
577+
method: str = "hcluster",
578+
):
579+
"""Reduce the site DataFrame to non-equivalent sites using SOAP
580+
descriptors and (hierarchical) clustering.
581+
582+
This is the SOAP analogue of :meth:`sym_reduce`.
583+
584+
Args:
585+
similarity_threshold (float, optional): Cosine similarity above
586+
which two sites are deemed equivalent. Defaults to ``0.99``.
587+
soap_params (dict, optional): SOAP hyper-parameters forwarded to
588+
``dscribe.descriptors.SOAP``.
589+
method (str, optional): ``"hcluster"`` (default) or ``"greedy"``.
590+
"""
591+
include = self.get_nonequivalent_sites_soap(
592+
similarity_threshold=similarity_threshold,
593+
soap_params=soap_params,
594+
method=method,
595+
)
422596
include_filter = [i in include for i in self.site_df.index.values]
423597
self.site_df = self.site_df[include_filter]
424598
self.site_dict = self.site_df.to_dict(orient="list")

0 commit comments

Comments
 (0)