@@ -158,6 +158,102 @@ def _fake_downsample(arrays, thin_radius):
158158 assert len (created_tifs ) >= 1 , "Expected at least one output tile for PAI."
159159
160160
161+ @patch ("pyforestscan.process.downsample_voxel" )
162+ @patch ("pyforestscan.process.pdal.Pipeline" )
163+ def test_process_with_tiles_voxelgrid_applied (mock_pipeline_cls , mock_downsample_voxel , tmp_path ):
164+ """
165+ When voxelgrid_cell is provided, ensure downsample_voxel is called and output is produced.
166+ """
167+ dtype = [("X" , "f8" ), ("Y" , "f8" ), ("HeightAboveGround" , "f8" )]
168+ pts = np .zeros (80 , dtype = dtype )
169+ pts ["X" ] = np .random .uniform (0 , 10 , size = 80 )
170+ pts ["Y" ] = np .random .uniform (0 , 10 , size = 80 )
171+ pts ["HeightAboveGround" ] = np .random .uniform (0 , 5 , size = 80 )
172+
173+ mock_pipeline = MagicMock ()
174+ mock_pipeline .execute .return_value = True
175+ mock_pipeline .arrays = [pts ]
176+ mock_pipeline_cls .return_value = mock_pipeline
177+
178+ def _fake_voxel (arrays , cell , mode ):
179+ arr = arrays [0 ]
180+ # Keep every 3rd point to mimic downsampling
181+ return [arr [::3 ]]
182+
183+ mock_downsample_voxel .side_effect = _fake_voxel
184+
185+ out_dir = tmp_path / "test_pai_voxelgrid"
186+ out_dir .mkdir ()
187+
188+ process_with_tiles (
189+ ept_file = "fake_ept_path" ,
190+ tile_size = (20 , 20 ),
191+ output_path = str (out_dir ),
192+ metric = "pai" ,
193+ voxel_size = (2 , 2 , 1 ),
194+ voxel_height = 1.0 ,
195+ buffer_size = 0.0 ,
196+ srs = "EPSG:32610" ,
197+ hag = False ,
198+ hag_dtm = False ,
199+ dtm = None ,
200+ bounds = ([0 , 20 ], [0 , 20 ], [0 , 10 ]),
201+ voxelgrid_cell = 1.5 ,
202+ voxelgrid_mode = "first" ,
203+ )
204+
205+ assert mock_downsample_voxel .called , "downsample_voxel should be called when voxelgrid_cell is set"
206+ created_tifs = list (out_dir .glob ("tile_*_pai.tif" ))
207+ assert len (created_tifs ) >= 1 , "Expected at least one output tile for PAI with voxel-grid downsampling."
208+
209+
210+ @patch ("pyforestscan.process.downsample_voxel" )
211+ @patch ("pyforestscan.process.pdal.Pipeline" )
212+ def test_process_with_tiles_voxelgrid_empty_skips (mock_pipeline_cls , mock_downsample_voxel , tmp_path ):
213+ """
214+ If voxel-grid thinning removes all points, the tile should be skipped gracefully.
215+ """
216+ dtype = [("X" , "f8" ), ("Y" , "f8" ), ("HeightAboveGround" , "f8" )]
217+ pts = np .zeros (30 , dtype = dtype )
218+ pts ["X" ] = np .random .uniform (0 , 10 , size = 30 )
219+ pts ["Y" ] = np .random .uniform (0 , 10 , size = 30 )
220+ pts ["HeightAboveGround" ] = np .random .uniform (0 , 1 , size = 30 )
221+
222+ mock_pipeline = MagicMock ()
223+ mock_pipeline .execute .return_value = True
224+ mock_pipeline .arrays = [pts ]
225+ mock_pipeline_cls .return_value = mock_pipeline
226+
227+ def _empty_voxel (arrays , cell , mode ):
228+ # Simulate all points removed by voxel downsampling
229+ empty = np .array ([], dtype = dtype )
230+ return [empty ]
231+
232+ mock_downsample_voxel .side_effect = _empty_voxel
233+
234+ out_dir = tmp_path / "test_voxelgrid_empty"
235+ out_dir .mkdir ()
236+
237+ process_with_tiles (
238+ ept_file = "fake_ept_path" ,
239+ tile_size = (20 , 20 ),
240+ output_path = str (out_dir ),
241+ metric = "fhd" ,
242+ voxel_size = (2 , 2 , 1 ),
243+ buffer_size = 0.0 ,
244+ srs = "EPSG:32610" ,
245+ hag = False ,
246+ hag_dtm = False ,
247+ dtm = None ,
248+ bounds = ([0 , 20 ], [0 , 20 ], [0 , 10 ]),
249+ voxelgrid_cell = 1.0 ,
250+ voxelgrid_mode = "first" ,
251+ verbose = True ,
252+ )
253+
254+ created_tifs = list (out_dir .glob ("*.tif" ))
255+ assert len (created_tifs ) == 0 , "No output should be produced when voxel-grid thinning empties the tile."
256+
161257@patch ("pyforestscan.process.pdal.Pipeline" )
162258def test_process_with_tiles_pai_handles_low_top_height (mock_pipeline_cls , tmp_path ):
163259 """
0 commit comments