@@ -285,6 +285,11 @@ def __init__(
285285 self ._registered_swarms = weakref .WeakSet ()
286286 self ._registered_surfaces = weakref .WeakSet () # Surfaces using this mesh
287287 self ._registered_submeshes = weakref .WeakSet () # Submeshes from extract_region
288+
289+ # _mesh_update_lock: Re-entrant lock to coordinate mesh deformation.
290+ # Held by mesh_update_callback during _deform_mesh(). Checked by
291+ # MeshVariable callbacks (blocking=False) to skip PETSc sync during
292+ # sensitive coordinate changes.
288293 self ._mesh_update_lock = threading .RLock ()
289294
290295 comm = PETSc .COMM_WORLD
@@ -594,14 +599,25 @@ class replacement_boundaries(Enum):
594599 # to handle that so we just wrap it here.
595600
596601 def mesh_update_callback (array , change_context ):
597- print (f"Mesh update callback - mesh deform" )
598- coords = array .reshape (- 1 , array .owner .cdim )
599- self ._deform_mesh (coords , verbose = True )
602+ mesh = array .owner
603+ if mesh is None :
604+ # This guard handles cases where the array is accessed during
605+ # object teardown (e.g. at application exit or during mesh
606+ # replacement), where the owning Python mesh object has already
607+ # been garbage collected but the NDArray proxy still exists.
608+ return
609+
610+ if verbose :
611+ uw .pprint (0 , f"Mesh update callback - mesh deform" )
612+
613+ coords = array .reshape (- 1 , mesh .cdim )
614+ mesh ._deform_mesh (coords , verbose = verbose )
600615
601616 # Increment mesh version to notify registered swarms of coordinate changes
602- with self ._mesh_update_lock :
603- self ._mesh_version += 1
604- print (f"Mesh version incremented to { self ._mesh_version } " )
617+ with mesh ._mesh_update_lock :
618+ mesh ._mesh_version += 1
619+ if verbose :
620+ uw .pprint (0 , f"Mesh version incremented to { mesh ._mesh_version } " )
605621
606622 return
607623
@@ -1239,11 +1255,12 @@ def _build_vertex_map(self):
12391255 Uses coordinate matching at extraction time (before any
12401256 deformation). Cached permanently since topology doesn't change.
12411257 """
1242- if hasattr (self , ' _vertex_map' ) and self ._vertex_map is not None :
1258+ if hasattr (self , " _vertex_map" ) and self ._vertex_map is not None :
12431259 return self ._vertex_map
12441260
1245- tree = uw .kdtree .KDTree (self .X .coords )
1246- dists , indices = tree .query (self .parent .X .coords , sqr_dists = False )
1261+ # Use cached KDTree from coordinate variable
1262+ tree = self .X ._get_kdtree ()
1263+ dists , indices = tree .query (self .parent .X .coords_nd , sqr_dists = False )
12471264 matched = dists < 1.0e-10
12481265
12491266 # parent_rows[i] -> sub_rows[i]: matched vertex pairs
@@ -1337,10 +1354,14 @@ def _re_extract_from_parent(self, verbose=False):
13371354 )
13381355
13391356 def mesh_update_callback (array , change_context ):
1340- coords = array .reshape (- 1 , array .owner .cdim )
1341- self ._deform_mesh (coords , verbose = False )
1342- with self ._mesh_update_lock :
1343- self ._mesh_version += 1
1357+ mesh = array .owner
1358+ if mesh is None :
1359+ return
1360+
1361+ coords = array .reshape (- 1 , mesh .cdim )
1362+ mesh ._deform_mesh (coords , verbose = False )
1363+ with mesh ._mesh_update_lock :
1364+ mesh ._mesh_version += 1
13441365 return
13451366
13461367 self ._coords .add_callback (mesh_update_callback )
@@ -1433,8 +1454,8 @@ def _build_dof_map(self, parent_var, sub_var):
14331454 if key in self ._dof_maps :
14341455 return self ._dof_maps [key ]
14351456
1436- tree = uw . kdtree . KDTree ( sub_var .coords )
1437- dists , indices = tree .query (parent_var .coords , sqr_dists = False )
1457+ tree = sub_var ._get_kdtree ( )
1458+ dists , indices = tree .query (parent_var .coords_nd , sqr_dists = False )
14381459 matched = dists < 1.0e-10
14391460
14401461 # indices[matched] maps parent row → sub row
@@ -1777,42 +1798,56 @@ def _deform_mesh(self, new_coords: numpy.ndarray, verbose=False):
17771798 The coord array that is passed in should match the shape of self.data
17781799 """
17791800
1780- coord_vec = self .dm .getCoordinatesLocal ()
1781- coords = coord_vec .array .reshape (- 1 , self .cdim )
1782- coords [...] = new_coords [...]
1783-
1784- self .dm .setCoordinatesLocal (coord_vec )
1785- self .nuke_coords_and_rebuild ()
1786-
1787- # Rebuild the _coords array view. nuke_coords_and_rebuild may
1788- # replace the coordinate vector internally (createCoordinateSpace),
1789- # leaving self._coords as a stale numpy view of the old buffer.
1790- import underworld3 .utilities
1791- old_callbacks = getattr (self ._coords , "_callbacks" , [])
1792- self ._coords = underworld3 .utilities .NDArray_With_Callback (
1793- numpy .ndarray .view (
1794- self .dm .getCoordinatesLocal ().array .reshape (- 1 , self .cdim )
1795- ),
1796- owner = self ,
1797- )
1798- for cb in old_callbacks :
1799- self ._coords .add_callback (cb )
1800-
1801- # BUGFIX(#122): mark registered solvers for rebuild. Since PR #127
1802- # ("Trust JIT cache: skip DM rebuild on constant-only parameter
1803- # changes") a solver with is_setup=True trusts its cached PETSc DM
1804- # / SNES assembly and skips rebuild on the next solve(). After a
1805- # coordinate change the cached DM still carries pre-deform
1806- # coordinates, so F(v_prev) ≈ 0 and the solver converges in 0
1807- # iterations without updating the solution. mesh.adapt() already
1808- # does this; _deform_mesh must match.
1809- for solver in self ._equation_systems_register :
1810- if solver is not None and hasattr (solver , "is_setup" ):
1811- solver .is_setup = False
1801+ with self ._mesh_update_lock :
1802+ coord_vec = self .dm .getCoordinatesLocal ()
1803+ coords = coord_vec .array .reshape (- 1 , self .cdim )
1804+ coords [...] = new_coords [...]
1805+
1806+ self .dm .setCoordinatesLocal (coord_vec )
1807+ self .nuke_coords_and_rebuild ()
1808+
1809+ # Rebuild the _coords array view. nuke_coords_and_rebuild may
1810+ # replace the coordinate vector internally (createCoordinateSpace),
1811+ # leaving self._coords as a stale numpy view of the old buffer.
1812+ import underworld3 .utilities
1813+ old_callbacks = getattr (self ._coords , "_callbacks" , [])
1814+ self ._coords = underworld3 .utilities .NDArray_With_Callback (
1815+ numpy .ndarray .view (
1816+ self .dm .getCoordinatesLocal ().array .reshape (- 1 , self .cdim )
1817+ ),
1818+ owner = self ,
1819+ )
1820+ for cb in old_callbacks :
1821+ self ._coords .add_callback (cb )
1822+
1823+ # BUGFIX(#122): mark registered solvers for rebuild. Since PR #127
1824+ # ("Trust JIT cache: skip DM rebuild on constant-only parameter
1825+ # changes") a solver with is_setup=True trusts its cached PETSc DM
1826+ # / SNES assembly and skips rebuild on the next solve(). After a
1827+ # coordinate change the cached DM still carries pre-deform
1828+ # coordinates, so F(v_prev) ≈ 0 and the solver converges in 0
1829+ # iterations without updating the solution. mesh.adapt() already
1830+ # does this; _deform_mesh must match.
1831+ for solver in self ._equation_systems_register :
1832+ if solver is not None and hasattr (solver , "is_setup" ):
1833+ solver .is_setup = False
1834+
1835+ # Invalidate caches whose contents become stale when mesh
1836+ # coordinates change. Matches the cache hygiene already
1837+ # performed by mesh.adapt() and _legacy_access. Without
1838+ # these, uw.function.evaluate (and any user code that keys
1839+ # lookups off _topology_version) can return values
1840+ # computed against the pre-deform mesh.
1841+ self ._evaluation_hash = None
1842+ self ._evaluation_interpolated_results = None
1843+ if hasattr (self , '_dminterpolation_cache' ):
1844+ self ._dminterpolation_cache .invalidate_all (
1845+ reason = "mesh deformed" )
1846+ self ._topology_version += 1
18121847
1813- # Propagate coordinate changes to registered submeshes
1814- for submesh in self ._registered_submeshes :
1815- submesh .sync_coordinates_from_parent ()
1848+ # Propagate coordinate changes to registered submeshes
1849+ for submesh in self ._registered_submeshes :
1850+ submesh .sync_coordinates_from_parent ()
18161851
18171852 return
18181853
@@ -3455,11 +3490,24 @@ def _get_mesh_centroids(self):
34553490 def _get_domain_centroids (self ):
34563491
34573492 import numpy as np
3493+ from underworld3 .utilities import gather_data
34583494
34593495 domain_centroid = self ._centroids .mean (axis = 0 )
34603496 all_centroids = gather_data (domain_centroid , bcast = True ).reshape (- 1 , self .dim )
34613497 return all_centroids
34623498
3499+ def _get_domain_kdtree (self ):
3500+ import underworld3 as uw
3501+ if (
3502+ not hasattr (self , "_domain_kdtree" )
3503+ or self ._domain_kdtree is None
3504+ or getattr (self , "_domain_kdtree_version" , - 1 ) != self ._mesh_version
3505+ ):
3506+ centroids = self ._get_domain_centroids ()
3507+ self ._domain_kdtree = uw .kdtree .KDTree (centroids )
3508+ self ._domain_kdtree_version = self ._mesh_version
3509+ return self ._domain_kdtree
3510+
34633511 def get_min_radius_old (self ) -> float :
34643512 """
34653513 This method returns the global minimum distance from any cell centroid to a face.
@@ -3777,12 +3825,15 @@ def adapt(self, metric_field, verbose=False):
37773825
37783826 # Rebuild the callback for mesh deformation
37793827 def mesh_update_callback (array , change_context ):
3780- print (f"Mesh update callback - mesh deform" )
3828+ if verbose :
3829+ uw .pprint (0 , f"Mesh update callback - mesh deform" )
3830+
37813831 coords = array .reshape (- 1 , array .owner .cdim )
3782- self ._deform_mesh (coords , verbose = True )
3832+ self ._deform_mesh (coords , verbose = verbose )
37833833 with self ._mesh_update_lock :
37843834 self ._mesh_version += 1
3785- print (f"Mesh version incremented to { self ._mesh_version } " )
3835+ if verbose :
3836+ uw .pprint (0 , f"Mesh version incremented to { self ._mesh_version } " )
37863837 return
37873838
37883839 self ._coords .add_callback (mesh_update_callback )
0 commit comments