From ce9e2c1357c4cfcb233c1f9554bdb6e56134525e Mon Sep 17 00:00:00 2001 From: Berk Geveci Date: Tue, 22 Sep 2026 11:38:33 -0400 Subject: [PATCH] fix(origin): correct the map window at +180 and after rotating back Two defects in the map-origin feature, both on the dycore (np4) path. Setting the origin to +180 blanked the map. EAMCenterMeridian's passthrough shortcut tested the origins modulo a turn, so with the dycore reader's [-180, 180) input a +180 origin looked like a window already in place and the filter did nothing. A window a whole turn away covers the same meridians but names them 360 degrees apart, and everything downstream reads coordinates, not residues: EAMProject then subtracted a full turn and clipped, collapsing the mesh onto the left rim. The passthrough now requires the two origins to match exactly, and a whole-turn offset takes a new branch in the rebuild -- the cut would land on the window's own edge and leave one side empty, so the mesh is simply translated rather than clipped. That is also ~14x cheaper per tick than the clip path it replaces. Rotating away, cropping latitude and rotating back to -180 produced a mesh cropped along the wrong cells. EAMExtract validated its cell-centre cache by comparing modified times, which only order events within one object; upstream legitimately hands back an *older* object than the cache was built from, because EAMCenterMeridian passes the reader's own points straight through once the map is back in the reader's window. Centres computed from the rotated mesh therefore looked fresh against the unrotated one, and the ghost mask was applied to a mesh it was not computed for. The cell-centre and output caches are now keyed on the identity of the geometry they were built from, plus the crop ranges, with a strong reference held so no other object can land on those addresses -- the same anchoring EAMProject already uses. Also lets SetMeridian drop the cached output, as SetLongitudeOrigin already does; it sets the same state and had the same staleness hole. Verified by sweeping 14 origin/crop combinations, including +/-180, +/-179 and seam-crossing crops, on both ne30np4 and ne30pg2, comparing point/cell counts and bounds against a freshly built pipeline at the same settings: all 28 match, and every stage still takes its cache-hit branch on a slicing tick. --- src/e3sm_quickview/plugins/eam_projection.py | 103 +++++++++++++------ 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/src/e3sm_quickview/plugins/eam_projection.py b/src/e3sm_quickview/plugins/eam_projection.py index 0ce792e..fc6f3fe 100644 --- a/src/e3sm_quickview/plugins/eam_projection.py +++ b/src/e3sm_quickview/plugins/eam_projection.py @@ -694,7 +694,12 @@ def __init__(self): self.lon_range = [-180.0, 180.0] self.lat_range = [-90.0, 90.0] self.cached_cell_centers = None + self._cached_geometry_key = None + #: Strong ref to the geometry the caches were built from, so nothing + #: else can be allocated at the same address while its id() is a key. + self._cached_geometry = None self._cached_output = None + self._cached_crop_key = None self._last_was_cropped = False def SetLongitudeRange(self, min, max): @@ -734,8 +739,25 @@ def RequestData(self, request, inInfo, outInfo): self._last_was_cropped = False return 1 - if self.cached_cell_centers and self.cached_cell_centers.GetMTime() >= max( - inData.GetPoints().GetMTime(), inData.GetCells().GetMTime() + # Name the incoming geometry by identity as well as by modified + # time. Modified times only order events within one object, and + # upstream can legitimately hand back an *older* object than the + # one these caches were built from: EAMCenterMeridian passes the + # reader's own points straight through whenever the map already + # sits in the reader's window. Comparing times alone then made a + # cache built from a rotated mesh look fresh, and the cell centres + # of one mesh were used to mask the cells of another. + in_points = inData.GetPoints() + in_cells = inData.GetCells() + geometry_key = ( + id(in_points), + in_points.GetMTime(), + id(in_cells), + in_cells.GetMTime(), + ) + if ( + self.cached_cell_centers is not None + and self._cached_geometry_key == geometry_key ): cell_centers = self.cached_cell_centers else: @@ -752,13 +774,14 @@ def RequestData(self, request, inInfo, outInfo): # previous cached_cell_centers, if any, # is available for garbage collection after this assignment self.cached_cell_centers = cell_centers + self._cached_geometry_key = geometry_key + self._cached_geometry = (in_points, in_cells) # get the numpy array for cell centers cc = numpy_support.vtk_to_numpy(cell_centers) - if self._cached_output and self._cached_output.GetMTime() >= max( - self.GetMTime(), inData.GetPoints().GetMTime(), cell_centers.GetMTime() - ): + crop_key = (geometry_key, tuple(self.lon_range), tuple(self.lat_range)) + if self._cached_output is not None and self._cached_crop_key == crop_key: with _perf.timed("extract.cache_hit"): add_cell_and_point_arrays(inData, outData, self._cached_output) else: @@ -816,6 +839,7 @@ def RequestData(self, request, inInfo, outInfo): self._cached_output = outData.NewInstance() self._cached_output.ShallowCopy(outData) + self._cached_crop_key = crop_key self._last_was_cropped = True return 1 @@ -885,8 +909,10 @@ def SetMeridian(self, meridian_): ) ) return - self._center_meridian = meridian_ - self.Modified() + if self._center_meridian != meridian_: + self._center_meridian = meridian_ + self._cached_output = None + self.Modified() def SetLongitudeOrigin(self, origin): """Left edge of the map; the right edge is 360 degrees further east.""" @@ -931,9 +957,13 @@ def RequestData(self, request, inInfo, outInfo): # Nothing to do when the input already sits in the requested # window -- the dycore reader's default case. Clipping here would # be a no-op that still rebuilds the points every pass, which also - # costs EAMProject its cache downstream. + # costs EAMProject its cache downstream. The two origins have to + # agree exactly: a window a whole turn away covers the same + # meridians but names them 360 degrees apart, and everything + # downstream -- the crop ranges, the projection's re-centring -- + # reads coordinates, not residues. origin = self._center_meridian - 180.0 - if (origin - self._input_origin) % 360.0 == 0.0: + if origin == self._input_origin: with _perf.timed("center_meridian.passthrough"): outData.ShallowCopy(inData) return 1 @@ -960,30 +990,39 @@ def RequestData(self, request, inInfo, outInfo): generate_ids.SetCellIdsArrayName("PedigreeIds") cut, shift_low, shift_high = _longitude_window( - self._center_meridian - 180.0, self._input_origin + origin, self._input_origin ) - plane = vtkPlane() - plane.SetOrigin([cut, 0.0, 0.0]) - plane.SetNormal([-1, 0, 0]) - # vtkClipPolyData hangs - clipL = vtkTableBasedClipDataSet() - clipL.SetClipFunction(plane) - clipL.SetInputConnection(generate_ids.GetOutputPort()) - with _perf.timed("center_meridian.clip_left"): - clipL.Update() - - plane.SetNormal([1, 0, 0]) - clipR = vtkTableBasedClipDataSet() - clipR.SetClipFunction(plane) - clipR.SetInputConnection(generate_ids.GetOutputPort()) - with _perf.timed("center_meridian.clip_right"): - clipR.Update() - - with _perf.timed("center_meridian.transform"): - halves = [ - _translated(clipL.GetOutput(), shift_low), - _translated(clipR.GetOutput(), shift_high), - ] + if (origin - self._input_origin) % 360.0 == 0.0: + # A whole turn away: the cut falls on the window's own + # edge, so one side comes back empty and there is + # nothing to rearrange. Sliding the whole mesh over is + # cheaper than clipping it and keeps every cell intact. + generate_ids.Update() + with _perf.timed("center_meridian.transform"): + halves = [_translated(generate_ids.GetOutput(), shift_high)] + else: + plane = vtkPlane() + plane.SetOrigin([cut, 0.0, 0.0]) + plane.SetNormal([-1, 0, 0]) + # vtkClipPolyData hangs + clipL = vtkTableBasedClipDataSet() + clipL.SetClipFunction(plane) + clipL.SetInputConnection(generate_ids.GetOutputPort()) + with _perf.timed("center_meridian.clip_left"): + clipL.Update() + + plane.SetNormal([1, 0, 0]) + clipR = vtkTableBasedClipDataSet() + clipR.SetClipFunction(plane) + clipR.SetInputConnection(generate_ids.GetOutputPort()) + with _perf.timed("center_meridian.clip_right"): + clipR.Update() + + with _perf.timed("center_meridian.transform"): + halves = [ + _translated(clipL.GetOutput(), shift_low), + _translated(clipR.GetOutput(), shift_high), + ] append = vtkAppendFilter() for half in halves: