@@ -1293,6 +1293,12 @@ def plot_2d_rotor_scan(results: dict,
12931293 if len (results ['scans' ]) != 2 :
12941294 raise InputError (f'results must represent a 2D rotor, got { len (results ["scans" ])} D' )
12951295
1296+ # Dispatch to sparse plotting for adaptive scans
1297+ if is_sparse_2d_scan (results ):
1298+ _plot_2d_rotor_scan_sparse (results , path = path , label = label , cmap = cmap ,
1299+ resolution = resolution , original_dihedrals = original_dihedrals )
1300+ return
1301+
12961302 results ['directed_scan' ] = clean_scan_results (results ['directed_scan' ])
12971303
12981304 # phis0 and phis1 correspond to columns and rows in energies, respectively
@@ -1357,9 +1363,9 @@ def plot_2d_rotor_scan(results: dict,
13571363 label = ' for ' + label if label else ''
13581364 plt .title (f'2D scan energies (kJ/mol){ label } ' )
13591365 min_x = min_y = - 180
1360- plt .xlim = (min_x , min_x + 360 )
1366+ plt .gca (). set_xlim (min_x , min_x + 360 )
13611367 plt .xticks (np .arange (min_x , min_x + 361 , step = 60 ))
1362- plt .ylim = (min_y , min_y + 360 )
1368+ plt .gca (). set_ylim (min_y , min_y + 360 )
13631369 plt .yticks (np .arange (min_y , min_y + 361 , step = 60 ))
13641370
13651371 if mark_lowest_conformations :
@@ -1379,6 +1385,207 @@ def plot_2d_rotor_scan(results: dict,
13791385 plt .close (fig = fig )
13801386
13811387
1388+ def is_sparse_2d_scan (results : dict ) -> bool :
1389+ """
1390+ Detect whether a 2D scan results dict represents a sparse/adaptive scan.
1391+
1392+ A scan is considered sparse if the results contain
1393+ ``sampling_policy == 'adaptive'``.
1394+
1395+ Args:
1396+ results (dict): The results dictionary from a 2D directed scan.
1397+
1398+ Returns:
1399+ bool: ``True`` if the scan is sparse/adaptive.
1400+ """
1401+ return results .get ('sampling_policy' ) == 'adaptive'
1402+
1403+
1404+ def extract_sparse_2d_points (results : dict ) -> dict :
1405+ """
1406+ Extract sampled point coordinates and energies from a sparse 2D scan result.
1407+
1408+ Args:
1409+ results (dict): The results dictionary from a 2D directed scan.
1410+
1411+ Returns:
1412+ dict: A dictionary with keys ``'x'``, ``'y'``, ``'energy'`` (lists of floats for
1413+ completed points with non-None energy), plus ``'failed_points'`` and
1414+ ``'invalid_points'`` (lists of ``[x, y]`` pairs).
1415+ """
1416+ xs , ys , energies = [], [], []
1417+ for key , entry in results .get ('directed_scan' , {}).items ():
1418+ e = entry .get ('energy' )
1419+ if e is not None :
1420+ xs .append (float (key [0 ]))
1421+ ys .append (float (key [1 ]))
1422+ energies .append (float (e ))
1423+ summary = results .get ('adaptive_scan_summary' , {})
1424+ return {
1425+ 'x' : xs ,
1426+ 'y' : ys ,
1427+ 'energy' : energies ,
1428+ 'failed_points' : summary .get ('failed_points' , []),
1429+ 'invalid_points' : summary .get ('invalid_points' , []),
1430+ }
1431+
1432+
1433+ def interpolate_sparse_2d_scan (points_x : list ,
1434+ points_y : list ,
1435+ energies : list ,
1436+ grid_resolution : float = 2.0 ,
1437+ ) -> tuple :
1438+ """
1439+ Interpolate sparse 2D scan data onto a dense grid for contour plotting.
1440+
1441+ Uses ``scipy.interpolate.griddata`` with periodic boundary augmentation
1442+ to reduce artifacts at the -180/+180 wrap boundary.
1443+
1444+ Args:
1445+ points_x (list): Sampled dihedral angles for dimension 0 (degrees).
1446+ points_y (list): Sampled dihedral angles for dimension 1 (degrees).
1447+ energies (list): Energy values at sampled points (kJ/mol).
1448+ grid_resolution (float): Spacing of the dense output grid in degrees.
1449+
1450+ Returns:
1451+ tuple: ``(grid_x, grid_y, grid_energies)`` where each is a 2D numpy array
1452+ suitable for ``plt.contourf``.
1453+ """
1454+ from scipy .interpolate import griddata
1455+
1456+ px = np .array (points_x , dtype = np .float64 )
1457+ py = np .array (points_y , dtype = np .float64 )
1458+ pe = np .array (energies , dtype = np .float64 )
1459+
1460+ # Augment with periodic image points for wrap-around
1461+ aug_x , aug_y , aug_e = list (px ), list (py ), list (pe )
1462+ for dx in (- 360.0 , 0.0 , 360.0 ):
1463+ for dy in (- 360.0 , 0.0 , 360.0 ):
1464+ if dx == 0.0 and dy == 0.0 :
1465+ continue
1466+ aug_x .extend (px + dx )
1467+ aug_y .extend (py + dy )
1468+ aug_e .extend (pe )
1469+ aug_x = np .array (aug_x )
1470+ aug_y = np .array (aug_y )
1471+ aug_e = np .array (aug_e )
1472+
1473+ # Dense grid from -180 to 180
1474+ n_pts = int (360.0 / grid_resolution ) + 1
1475+ gx = np .linspace (- 180.0 , 180.0 , n_pts )
1476+ gy = np .linspace (- 180.0 , 180.0 , n_pts )
1477+ grid_x , grid_y = np .meshgrid (gx , gy , indexing = 'ij' )
1478+
1479+ # Interpolate: try cubic, fall back to linear, then nearest
1480+ pts = np .column_stack ([aug_x , aug_y ])
1481+ grid_e = None
1482+ for method in ('cubic' , 'linear' ):
1483+ try :
1484+ grid_e = griddata (pts , aug_e , (grid_x , grid_y ), method = method )
1485+ if not np .all (np .isnan (grid_e )):
1486+ break
1487+ except (ValueError , Exception ):
1488+ grid_e = None
1489+ if grid_e is None or np .all (np .isnan (grid_e )):
1490+ grid_e = griddata (pts , aug_e , (grid_x , grid_y ), method = 'nearest' )
1491+ # Fill any remaining NaN with nearest-neighbor
1492+ mask = np .isnan (grid_e )
1493+ if mask .any ():
1494+ grid_nearest = griddata (pts , aug_e , (grid_x , grid_y ), method = 'nearest' )
1495+ grid_e [mask ] = grid_nearest [mask ]
1496+
1497+ return grid_x , grid_y , grid_e
1498+
1499+
1500+ def _plot_2d_rotor_scan_sparse (results : dict ,
1501+ path : Optional [str ] = None ,
1502+ label : str = '' ,
1503+ cmap : str = 'Blues' ,
1504+ resolution : int = 90 ,
1505+ original_dihedrals : Optional [List [float ]] = None ,
1506+ ):
1507+ """
1508+ Plot a sparse/adaptive 2D rotor scan using interpolation for contours
1509+ and overlaying sampled, failed, and invalid points.
1510+
1511+ This is called internally by :func:`plot_2d_rotor_scan` when the results
1512+ are detected as sparse.
1513+
1514+ Args:
1515+ results (dict): The results dictionary from a 2D directed scan.
1516+ path (str, optional): Folder path to save the plot image.
1517+ label (str, optional): Species label.
1518+ cmap (str, optional): Matplotlib colormap name.
1519+ resolution (int, optional): Image DPI.
1520+ original_dihedrals (list, optional): Original dihedral angles for marker.
1521+ """
1522+ data = extract_sparse_2d_points (results )
1523+ xs , ys , energies = data ['x' ], data ['y' ], data ['energy' ]
1524+
1525+ if len (xs ) < 3 :
1526+ logger .warning (f'Not enough sparse points to plot 2D scan ({ len (xs )} points)' )
1527+ return
1528+
1529+ # Normalize energies to min = 0
1530+ e_min = min (energies )
1531+ energies_norm = [e - e_min for e in energies ]
1532+
1533+ # Interpolate to dense grid
1534+ grid_x , grid_y , grid_e = interpolate_sparse_2d_scan (xs , ys , energies_norm , grid_resolution = 2.0 )
1535+
1536+ fig = plt .figure (num = None , figsize = (12 , 8 ), dpi = resolution , facecolor = 'w' , edgecolor = 'k' )
1537+
1538+ plt .contourf (grid_x , grid_y , grid_e , 20 , cmap = cmap )
1539+ plt .colorbar ()
1540+ contours = plt .contour (grid_x , grid_y , grid_e , 4 , colors = 'black' )
1541+ plt .clabel (contours , inline = True , fontsize = 8 )
1542+
1543+ # Overlay sampled points
1544+ plt .scatter (xs , ys , c = 'black' , s = 12 , zorder = 5 , label = 'sampled' )
1545+
1546+ # Overlay failed points
1547+ failed = data .get ('failed_points' , [])
1548+ if failed :
1549+ fx = [p [0 ] for p in failed ]
1550+ fy = [p [1 ] for p in failed ]
1551+ plt .scatter (fx , fy , c = 'red' , marker = 'x' , s = 40 , zorder = 6 , label = 'failed' )
1552+
1553+ # Overlay invalid points
1554+ invalid = data .get ('invalid_points' , [])
1555+ if invalid :
1556+ ix = [p [0 ] for p in invalid ]
1557+ iy = [p [1 ] for p in invalid ]
1558+ plt .scatter (ix , iy , edgecolors = 'orange' , marker = 's' , facecolors = 'none' ,
1559+ s = 40 , zorder = 6 , label = 'invalid' )
1560+
1561+ # Mark original dihedral
1562+ if original_dihedrals is not None and len (original_dihedrals ) >= 2 :
1563+ plt .plot (original_dihedrals [0 ], original_dihedrals [1 ], color = 'r' ,
1564+ marker = '.' , markersize = 15 , linewidth = 0 , label = 'original' )
1565+
1566+ plt .xlabel (f'Dihedral 1 for { results ["scans" ][0 ]} (degrees)' )
1567+ plt .ylabel (f'Dihedral 2 for { results ["scans" ][1 ]} (degrees)' )
1568+ label_str = ' for ' + label if label else ''
1569+ summary = results .get ('adaptive_scan_summary' , {})
1570+ n_pts = summary .get ('completed_count' , len (xs ))
1571+ plt .title (f'2D scan energies (kJ/mol){ label_str } [adaptive, { n_pts } pts]' )
1572+ plt .gca ().set_xlim (- 180 , 180 )
1573+ plt .xticks (np .arange (- 180 , 181 , step = 60 ))
1574+ plt .gca ().set_ylim (- 180 , 180 )
1575+ plt .yticks (np .arange (- 180 , 181 , step = 60 ))
1576+
1577+ plt .legend (loc = 'upper right' , fontsize = 8 )
1578+
1579+ if path is not None :
1580+ fig_name = f'{ results ["directed_scan_type" ]} _{ results ["scans" ]} _adaptive.png'
1581+ fig_path = os .path .join (path , fig_name )
1582+ plt .savefig (fig_path , dpi = resolution , facecolor = 'w' , edgecolor = 'w' , orientation = 'portrait' ,
1583+ format = 'png' , transparent = False , bbox_inches = None , pad_inches = 0.1 , metadata = None )
1584+
1585+ plt .show ()
1586+ plt .close (fig = fig )
1587+
1588+
13821589def plot_2d_scan_bond_dihedral (results : dict ,
13831590 path : Optional [str ] = None ,
13841591 label : str = '' ,
@@ -1486,7 +1693,7 @@ def plot_2d_scan_bond_dihedral(results: dict,
14861693 label = ' for ' + label if label else ''
14871694 plt .title (f'2D scan energies (kJ/mol){ label } ' )
14881695 min_x = - 180
1489- plt .xlim = (min_x , min_x + 360 )
1696+ plt .gca (). set_xlim (min_x , min_x + 360 )
14901697 plt .xticks (np .arange (min_x , min_x + 361 , step = 60 ))
14911698
14921699 if original_dihedrals is not None :
0 commit comments