Parameter Search¶
Before running A* to find transformation pathways, we need to choose discretization parameters. The CNF representation depends on two resolution parameters: xi (lattice discretization in Angstroms squared) and delta (motif discretization, dividing fractional coordinates into delta intervals). Additionally, we often filter the search space using a minimum interatomic distance constraint to avoid unphysical configurations.
This guide explains how to search for good parameters automatically. The goal is to find the finest resolution (smallest xi, largest delta) that still allows pathfinding to succeed, with the strictest possible minimum distance filter.
Why Parameter Choice Matters¶
The discretization parameters control a fundamental tradeoff. Coarse resolution (large xi, small delta) makes the search space smaller and pathfinding faster, but the discrete approximation to the true crystal geometry is rough. Fine resolution gives a more accurate representation of crystal transformations but creates a much larger search space where A* may fail to find a path within iteration limits.
The minimum distance filter adds another dimension. Setting it high excludes many configurations where atoms get too close, making paths more physically realistic. But if it's too strict, there may be no valid path at all: the search space becomes disconnected because all intermediate configurations violate the distance constraint.
Finding the right balance requires experimentation. The parameter search algorithm automates this by trying multiple resolution levels and using binary search to find the maximum min_distance that permits pathfinding at each level.
Resolution Levels¶
Rather than searching a continuous parameter space, we define discrete resolution levels. Each level consists of:
- A value for xi (lattice resolution in Angstroms squared)
- A target atom step length (in Angstroms), which determines delta
The default levels progress from coarse to fine:
| Level | xi (Ang^2) | Atom step (Ang) |
|---|---|---|
| 1 | 1.5 | 0.4 |
| 2 | 1.25 | 0.3 |
| 3 | 1.0 | 0.2 |
| 4 | 0.75 | 0.1 |
At each level, the algorithm tries to find a path. If successful, it also determines the maximum min_distance constraint that still allows pathfinding. The finest resolution where pathfinding succeeds becomes the recommended parameter set.
From Atom Step to Delta¶
The atom step length is a physical quantity (Angstroms), while delta is an integer that divides the unit cell. The conversion depends on the cell size: delta = ceil(max_lattice_parameter / atom_step).
This means the same atom_step gives different delta values for different structures. A structure with a 10 Angstrom cell axis and a 0.2 Angstrom step target would get delta = 50, while a 5 Angstrom cell would get delta = 25.
When comparing two structures with different atom counts (requiring supercells to match), the delta is computed from the supercell lattice parameters, not the primitive cell. This ensures consistent physical resolution across the transformation pathway.
Binary Search for Minimum Distance¶
At each resolution level, we don't just check whether pathfinding succeeds. We also find the maximum min_distance filter that still permits a path. This is done via binary search.
The binary search starts with a range (by default 0.5 to 2.0 Angstroms, though appropriate values are highly chemistry-dependent). It tries the midpoint, runs A* search, and if a path is found, it knows the true maximum is higher, so it searches the upper half. If no path is found, the constraint is too strict, so it searches the lower half. This continues until the range is narrower than some tolerance (typically 0.05 Angstroms).
The result is the highest min_distance (to within tolerance) that still allows pathfinding. Higher values mean the path avoids configurations where atoms get close, which typically correlates with lower energies and more physical pathways.
A Simple Example¶
Let's search for parameters connecting two zirconium structures: HCP and a slightly strained variant. These differ only in lattice parameters, so the path will involve lattice neighbors (strain transformations) rather than motif neighbors (atomic shuffles).
from pymatgen.core import Structure, Lattice
from cnf import UnitCell
# HCP zirconium (2 atoms per cell)
a, c = 3.23, 5.15
lattice_start = Lattice.hexagonal(a, c)
zr_start = Structure(
lattice_start,
["Zr", "Zr"],
[[0, 0, 0], [1/3, 2/3, 0.5]]
)
# Strained version (5% larger a parameter)
lattice_goal = Lattice.hexagonal(a * 1.05, c)
zr_goal = Structure(
lattice_goal,
["Zr", "Zr"],
[[0, 0, 0], [1/3, 2/3, 0.5]]
)
start_uc = UnitCell.from_pymatgen_structure(zr_start)
end_uc = UnitCell.from_pymatgen_structure(zr_goal)
print(f"Start volume: {zr_start.volume:.2f} Ang^3")
print(f"Goal volume: {zr_goal.volume:.2f} Ang^3")
Start volume: 46.53 Ang^3 Goal volume: 51.30 Ang^3
from cnf.navigation.astar.iterative import search
# Use just 2 resolution levels for a quick demo
result = search(
start_uc,
end_uc,
xi_values=[2.0, 1.5],
atom_step_lengths=[0.5, 0.4],
max_iterations=1000,
tolerance=0.2, # Coarse tolerance for speed
verbosity=1,
)
[tensorpotential] Info: Environment variable TF_USE_LEGACY_KERAS is automatically set to '1'.
Parameter search: 2 resolution levels xi values: [2.0, 1.5] atom step lengths: [0.5, 0.4] min_distance range: [0.50, 2.00] Å Auto workers: 3 Running 2 resolutions with 3 workers...
[xi=2.0, atom_step=0.5] Resolution: xi=2.0, atom_step=0.5 Å -> delta=11 [xi=2.0, atom_step=0.5] 1 start CNFs, 1 goal CNFs [xi=2.0, atom_step=0.5] Binary search for min_distance in [0.50, 2.00] [xi=2.0, atom_step=0.5] trying min_distance=1.250 Å... path found (4 iters) [xi=2.0, atom_step=0.5] trying min_distance=1.625 Å... path found (4 iters) [xi=2.0, atom_step=0.5] trying min_distance=1.812 Å... [xi=1.5, atom_step=0.4] Resolution: xi=1.5, atom_step=0.4 Å -> delta=13 [xi=1.5, atom_step=0.4] 1 start CNFs, 1 goal CNFs [xi=1.5, atom_step=0.4] Binary search for min_distance in [0.50, 2.00] [xi=1.5, atom_step=0.4] trying min_distance=1.250 Å... [xi=2.0, delta=11] min_distance=1.812 Å path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.625 Å... path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.812 Å... [xi=1.5, delta=13] min_distance=1.812 Å path found (2 iters) path found (4 iters)
============================================================ Parameter search complete: Successful resolutions: 2/2 Recommended: xi=1.5, delta=13, min_distance=1.812 Å Total time: 4.6s ============================================================
The search function tries each resolution level in sequence, running binary search at each to find the optimal min_distance. The output shows which resolutions succeeded and what parameters were found.
print(f"Search succeeded: {result.success}")
print(f"Successful resolutions: {len(result.successful_params)}")
print()
print("Recommended parameters:")
print(f" xi = {result.recommended_xi}")
print(f" delta = {result.recommended_delta}")
print(f" min_distance = {result.recommended_min_distance:.3f} Ang")
Search succeeded: True Successful resolutions: 2 Recommended parameters: xi = 1.5 delta = 13 min_distance = 1.812 Ang
Understanding the Result¶
The ParameterSearchResult contains detailed information about what was tried and what succeeded.
The successful_params list contains (xi, delta, min_distance) tuples for each resolution level where pathfinding succeeded. The recommendation takes the finest (last) successful resolution, since finer resolution typically gives more accurate paths.
The results list contains a SearchResult for each resolution level, including those that failed. This can be useful for debugging or understanding why certain resolutions didn't work.
print("All successful parameters:")
for xi, delta, min_dist in result.successful_params:
print(f" xi={xi}, delta={delta}, min_distance={min_dist:.3f}")
print(f"\nTotal resolution levels tried: {len(result.results)}")
All successful parameters: xi=2.0, delta=11, min_distance=1.812 xi=1.5, delta=13, min_distance=1.812 Total resolution levels tried: 2
Parallel Search¶
When multiple resolution levels need to be tested, they can be searched in parallel. Each level is independent: the binary search at one resolution doesn't depend on results from other resolutions.
The n_workers parameter controls parallelization. Setting it to 0 (the default) uses automatic selection based on available CPU cores. Setting it to 1 forces serial execution, which can be useful for debugging or when running on systems where parallelization is problematic.
# Parallel search with 2 workers
result_parallel = search(
start_uc,
end_uc,
xi_values=[2.0, 1.5],
atom_step_lengths=[0.5, 0.4],
max_iterations=1000,
tolerance=0.2,
n_workers=2,
verbosity=1,
)
Parameter search: 2 resolution levels xi values: [2.0, 1.5] atom step lengths: [0.5, 0.4] min_distance range: [0.50, 2.00] Å Running 2 resolutions with 2 workers...
[xi=1.5, atom_step=0.4] Resolution: xi=1.5, atom_step=0.4 Å -> delta=13 [xi=1.5, atom_step=0.4] 1 start CNFs, 1 goal CNFs [xi=1.5, atom_step=0.4] Binary search for min_distance in [0.50, 2.00] [xi=1.5, atom_step=0.4] trying min_distance=1.250 Å... [xi=2.0, atom_step=0.5] Resolution: xi=2.0, atom_step=0.5 Å -> delta=11 [xi=2.0, atom_step=0.5] 1 start CNFs, 1 goal CNFs [xi=2.0, atom_step=0.5] Binary search for min_distance in [0.50, 2.00] [xi=2.0, atom_step=0.5] trying min_distance=1.250 Å... path found (4 iters) [xi=2.0, atom_step=0.5] trying min_distance=1.625 Å... path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.625 Å... path found (4 iters) [xi=2.0, atom_step=0.5] trying min_distance=1.812 Å... path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.812 Å... [xi=2.0, delta=11] min_distance=1.812 Å [xi=1.5, delta=13] min_distance=1.812 Å path found (2 iters) path found (4 iters)
============================================================ Parameter search complete: Successful resolutions: 2/2 Recommended: xi=1.5, delta=13, min_distance=1.812 Å Total time: 4.6s ============================================================
Custom Resolution Levels¶
The default resolution levels work well for many systems, but you can customize them for specific needs. Perhaps you already know a coarse resolution works and want to focus on finer ones, or you want to try more extreme values.
The xi_values and atom_step_lengths parameters must be lists of the same length. Each position defines one resolution level.
# Custom resolution levels example
result_fine = search(
start_uc,
end_uc,
xi_values=[1.5, 1.25],
atom_step_lengths=[0.4, 0.3],
max_iterations=1000,
tolerance=0.2,
verbosity=1,
)
Parameter search: 2 resolution levels xi values: [1.5, 1.25] atom step lengths: [0.4, 0.3] min_distance range: [0.50, 2.00] Å Auto workers: 3 Running 2 resolutions with 3 workers...
[xi=1.25, atom_step=0.3] Resolution: xi=1.25, atom_step=0.3 Å -> delta=18 [xi=1.25, atom_step=0.3] 1 start CNFs, 1 goal CNFs [xi=1.25, atom_step=0.3] Binary search for min_distance in [0.50, 2.00] [xi=1.25, atom_step=0.3] trying min_distance=1.250 Å... [xi=1.5, atom_step=0.4] Resolution: xi=1.5, atom_step=0.4 Å -> delta=13 [xi=1.5, atom_step=0.4] 1 start CNFs, 1 goal CNFs [xi=1.5, atom_step=0.4] Binary search for min_distance in [0.50, 2.00] [xi=1.5, atom_step=0.4] trying min_distance=1.250 Å... path found (7 iters) [xi=1.25, atom_step=0.3] trying min_distance=1.625 Å... path found (7 iters) [xi=1.25, atom_step=0.3] trying min_distance=1.812 Å... path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.625 Å... path found (2 iters) [xi=1.5, atom_step=0.4] trying min_distance=1.812 Å... [xi=1.5, delta=13] min_distance=1.812 Å [xi=1.25, delta=18] min_distance=1.812 Å path found (7 iters) path found (2 iters)
============================================================ Parameter search complete: Successful resolutions: 2/2 Recommended: xi=1.25, delta=18, min_distance=1.812 Å Total time: 4.6s ============================================================
Adjusting the Binary Search Range¶
The binary search for min_distance uses default bounds of 0.5 to 2.0 Angstroms. These bounds are highly chemistry-dependent and may need adjustment for your system.
For structures with short bonds (hydrogen-containing compounds, for example), you might lower both bounds. For oxides or other systems with longer typical bond lengths, the defaults may work well. The key is that the lower bound should be permissive enough that paths can always be found, while the upper bound should be strict enough to exclude clearly unphysical configurations.
The tolerance parameter controls when binary search stops. Smaller values give more precise results but require more A* runs.
# Search with custom min_distance bounds
result_custom = search(
start_uc,
end_uc,
xi_values=[2.0],
atom_step_lengths=[0.5],
min_dist_low=1.0,
min_dist_high=2.5,
tolerance=0.2,
max_iterations=1000,
verbosity=1,
)
Parameter search: 1 resolution levels
xi values: [2.0]
atom step lengths: [0.5]
min_distance range: [1.00, 2.50] Å
Auto workers: 3
[1/1] Resolution: xi=2.0, atom_step=0.5 Å -> delta=11
1 start CNFs, 1 goal CNFs
Binary search for min_distance in [1.00, 2.50]
trying min_distance=1.750 Å...
path found (4 iters)
trying min_distance=2.125 Å...
path found (4 iters)
trying min_distance=2.312 Å...
path found (4 iters) ============================================================ Parameter search complete: Successful resolutions: 1/1 Recommended: xi=2.0, delta=11, min_distance=2.312 Å Total time: 0.1s ============================================================
Saving Results¶
The search result can be saved to JSON for later use. This is especially useful when parameter search takes a long time and you want to reuse the results without re-running the search.
The output_dir parameter causes the result to be saved automatically. Alternatively, you can call to_json() explicitly.
import tempfile
import os
with tempfile.TemporaryDirectory() as tmpdir:
# Save during search
result_saved = search(
start_uc,
end_uc,
xi_values=[2.0],
atom_step_lengths=[0.5],
max_iterations=1000,
tolerance=0.2,
output_dir=tmpdir,
verbosity=0,
)
# Check the saved file
saved_path = os.path.join(tmpdir, "parameter_search_result.json")
print(f"Saved to: {saved_path}")
print(f"File exists: {os.path.exists(saved_path)}")
[Search] Saved: /var/folders/20/rp8b8xzd0tn81qzlh8zmmvg00000gn/T/tmpglo7zjvq/parameter_search_result.json Saved to: /var/folders/20/rp8b8xzd0tn81qzlh8zmmvg00000gn/T/tmpglo7zjvq/parameter_search_result.json File exists: True
# Loading a saved result
from cnf.navigation.astar.models import ParameterSearchResult
with tempfile.TemporaryDirectory() as tmpdir:
outpath = os.path.join(tmpdir, "search_result.json")
result.to_json(outpath)
loaded = ParameterSearchResult.from_json(outpath)
print(f"Loaded recommended xi: {loaded.recommended_xi}")
print(f"Loaded recommended delta: {loaded.recommended_delta}")
Loaded recommended xi: 1.5 Loaded recommended delta: 13
Using the Recommended Parameters¶
The recommended parameters from the search can be used directly for A* pathfinding. They represent a good balance: fine enough resolution for accurate paths, but coarse enough that pathfinding succeeds reliably with the strictest distance constraint that permits connectivity.
You might also use these parameters as a starting point and then manually adjust them. For example, if you want even finer resolution and are willing to accept longer search times, you could decrease xi further. Or if you want to explore what happens with a more permissive distance filter, you could lower min_distance from the recommended value.
Summary¶
Parameter search automates the process of finding good CNF discretization parameters. It tries multiple resolution levels from coarse to fine, using binary search at each level to find the maximum min_distance filter that permits pathfinding.
The key outputs are:
- Recommended xi (lattice resolution)
- Recommended delta (motif resolution, computed from atom step length and cell size)
- Recommended min_distance (maximum value that allows pathfinding)
These parameters can then be used with the A* pathfinding algorithm to find transformation pathways between the two structures.