@@ -3396,6 +3396,179 @@ def test_grid_matchup_with_xoak_returns_nearest_value(
33963396 assert len (result ) == 1
33973397 assert not math .isnan (result .loc [0 , "sst" ])
33983398
3399+ def test_grid_matchup_xoak_global_granule_returns_nearest_value (
3400+ self , tmp_path : pathlib .Path , monkeypatch : pytest .MonkeyPatch
3401+ ) -> None :
3402+ """geometry='grid' + xoak on a global granule slices correctly and returns a value."""
3403+ pytest .importorskip ("xoak" ) # skip if xoak not installed
3404+
3405+ # Large global grid (181 lats × 361 lons) with the query point near centre.
3406+ lats = list (range (- 90 , 91 )) # integers -90, -89, …, 90
3407+ lons = list (range (- 180 , 181 )) # integers -180, -179, …, 180
3408+ nc_path = str (tmp_path / "global_grid.nc" )
3409+ _make_l3_dataset (lats , lons , seed = 99 ).to_netcdf (nc_path , engine = "netcdf4" )
3410+
3411+ mock_ea = MagicMock ()
3412+ mock_ea .open .return_value = [nc_path ]
3413+ monkeypatch .setitem (__import__ ("sys" ).modules , "earthaccess" , mock_ea )
3414+
3415+ # Query a single point at (lat=10, lon=20).
3416+ pts = pd .DataFrame (
3417+ {
3418+ "lat" : [10.0 ],
3419+ "lon" : [20.0 ],
3420+ "time" : pd .to_datetime (["2023-06-01T12:00:00" ]),
3421+ }
3422+ )
3423+ gm = GranuleMeta (
3424+ granule_id = "https://example.com/global_grid.nc" ,
3425+ begin = pd .Timestamp ("2023-06-01T00:00:00Z" ),
3426+ end = pd .Timestamp ("2023-06-01T23:59:59Z" ),
3427+ bbox = (- 180.0 , - 90.0 , 180.0 , 90.0 ),
3428+ result_index = 0 ,
3429+ )
3430+ p = Plan (
3431+ points = pts ,
3432+ results = [object ()],
3433+ granules = [gm ],
3434+ point_granule_map = {0 : [0 ]},
3435+ source_kwargs = {"short_name" : "TEST" },
3436+ time_buffer = pd .Timedelta (0 ),
3437+ )
3438+
3439+ result = pc .matchup (
3440+ p ,
3441+ geometry = "grid" ,
3442+ variables = ["sst" ],
3443+ spatial_method = "xoak" ,
3444+ open_dataset_kwargs = {"engine" : "netcdf4" },
3445+ )
3446+
3447+ assert "sst" in result .columns
3448+ assert len (result ) == 1
3449+ assert not math .isnan (result .loc [0 , "sst" ])
3450+
3451+
3452+ class TestSliceGridToPoints :
3453+ """Unit tests for the _slice_grid_to_points helper."""
3454+
3455+ def test_slices_ascending_coords (self ) -> None :
3456+ """Dataset with ascending lat/lon is sliced to the point bounding box + buffer."""
3457+ from point_collocation .core .engine import _slice_grid_to_points
3458+
3459+ lats = list (range (- 90 , 91 ))
3460+ lons = list (range (- 180 , 181 ))
3461+ ds = xr .Dataset (
3462+ {"sst" : (["lat" , "lon" ], np .zeros ((len (lats ), len (lons ))))},
3463+ coords = {"lat" : lats , "lon" : lons },
3464+ )
3465+
3466+ sliced = _slice_grid_to_points (ds , [10.0 ], [20.0 ], "lat" , "lon" , buffer_deg = 2.0 )
3467+
3468+ # The slice should cover [8, 12] lat and [18, 22] lon (within 2° buffer).
3469+ assert float (sliced ["lat" ].min ()) >= 8.0
3470+ assert float (sliced ["lat" ].max ()) <= 12.0
3471+ assert float (sliced ["lon" ].min ()) >= 18.0
3472+ assert float (sliced ["lon" ].max ()) <= 22.0
3473+ # Original dataset should be much larger.
3474+ assert sliced .sizes ["lat" ] < ds .sizes ["lat" ]
3475+ assert sliced .sizes ["lon" ] < ds .sizes ["lon" ]
3476+
3477+ def test_slices_descending_lat_coords (self ) -> None :
3478+ """Dataset with descending lat (90→-90) is sliced correctly."""
3479+ from point_collocation .core .engine import _slice_grid_to_points
3480+
3481+ lats = list (range (90 , - 91 , - 1 )) # integers 90, 89, …, -90 (descending)
3482+ lons = list (range (- 180 , 181 ))
3483+ ds = xr .Dataset (
3484+ {"sst" : (["lat" , "lon" ], np .zeros ((len (lats ), len (lons ))))},
3485+ coords = {"lat" : lats , "lon" : lons },
3486+ )
3487+
3488+ sliced = _slice_grid_to_points (ds , [5.0 ], [0.0 ], "lat" , "lon" , buffer_deg = 1.0 )
3489+
3490+ assert sliced .sizes ["lat" ] > 0
3491+ assert sliced .sizes ["lon" ] > 0
3492+ assert sliced .sizes ["lat" ] < ds .sizes ["lat" ]
3493+
3494+ def test_single_point_uses_buffer (self ) -> None :
3495+ """A single query point still produces a non-empty slice thanks to the buffer."""
3496+ from point_collocation .core .engine import _slice_grid_to_points
3497+
3498+ lats = list (range (- 90 , 91 ))
3499+ lons = list (range (- 180 , 181 ))
3500+ ds = xr .Dataset (
3501+ {"sst" : (["lat" , "lon" ], np .zeros ((len (lats ), len (lons ))))},
3502+ coords = {"lat" : lats , "lon" : lons },
3503+ )
3504+
3505+ sliced = _slice_grid_to_points (ds , [0.0 ], [0.0 ], "lat" , "lon" , buffer_deg = 1.0 )
3506+
3507+ # 1° buffer each side → at least 3 lat values and 3 lon values.
3508+ assert sliced .sizes ["lat" ] >= 3
3509+ assert sliced .sizes ["lon" ] >= 3
3510+
3511+ def test_empty_slice_falls_back_to_full_dataset (self ) -> None :
3512+ """If the buffered box is outside the grid, the full dataset is returned."""
3513+ from point_collocation .core .engine import _slice_grid_to_points
3514+
3515+ lats = [0.0 , 1.0 , 2.0 ]
3516+ lons = [0.0 , 1.0 , 2.0 ]
3517+ ds = xr .Dataset (
3518+ {"sst" : (["lat" , "lon" ], np .zeros ((3 , 3 )))},
3519+ coords = {"lat" : lats , "lon" : lons },
3520+ )
3521+
3522+ # Query point far outside the dataset range.
3523+ sliced = _slice_grid_to_points (ds , [50.0 ], [50.0 ], "lat" , "lon" , buffer_deg = 0.5 )
3524+
3525+ # Should fall back to the full dataset unchanged.
3526+ assert sliced .sizes ["lat" ] == ds .sizes ["lat" ]
3527+ assert sliced .sizes ["lon" ] == ds .sizes ["lon" ]
3528+
3529+ def test_2d_coords_returns_unchanged (self ) -> None :
3530+ """2-D (swath-style) coordinates are not sliced."""
3531+ from point_collocation .core .engine import _slice_grid_to_points
3532+
3533+ lat_2d = np .array ([[0.0 , 1.0 ], [2.0 , 3.0 ]])
3534+ lon_2d = np .array ([[10.0 , 11.0 ], [12.0 , 13.0 ]])
3535+ ds = xr .Dataset (
3536+ {"sst" : (["nrows" , "ncols" ], np .zeros ((2 , 2 )))},
3537+ coords = {
3538+ "lat" : (["nrows" , "ncols" ], lat_2d ),
3539+ "lon" : (["nrows" , "ncols" ], lon_2d ),
3540+ },
3541+ )
3542+
3543+ sliced = _slice_grid_to_points (ds , [1.0 ], [11.0 ], "lat" , "lon" )
3544+
3545+ # 2-D coords → no slicing; sizes must be unchanged.
3546+ assert sliced .sizes == ds .sizes
3547+
3548+ def test_multiple_points_uses_union_bbox (self ) -> None :
3549+ """Multiple query points: slice covers the union bounding box."""
3550+ from point_collocation .core .engine import _slice_grid_to_points
3551+
3552+ lats = list (range (- 90 , 91 ))
3553+ lons = list (range (- 180 , 181 ))
3554+ ds = xr .Dataset (
3555+ {"sst" : (["lat" , "lon" ], np .zeros ((len (lats ), len (lons ))))},
3556+ coords = {"lat" : lats , "lon" : lons },
3557+ )
3558+
3559+ # Two points that are far apart; the slice must cover both.
3560+ sliced = _slice_grid_to_points (
3561+ ds , [- 30.0 , 30.0 ], [- 60.0 , 60.0 ], "lat" , "lon" , buffer_deg = 1.0
3562+ )
3563+
3564+ assert float (sliced ["lat" ].min ()) <= - 30.0
3565+ assert float (sliced ["lat" ].max ()) >= 30.0
3566+ assert float (sliced ["lon" ].min ()) <= - 60.0
3567+ assert float (sliced ["lon" ].max ()) >= 60.0
3568+ # Still smaller than the full global grid.
3569+ assert sliced .sizes ["lat" ] < ds .sizes ["lat" ]
3570+ assert sliced .sizes ["lon" ] < ds .sizes ["lon" ]
3571+
33993572
34003573class TestShowVariablesLayout :
34013574 """Tests for plan.show_variables(geometry=...) with both open methods."""
0 commit comments