"""Tests for DTM module.""" import json import numpy as np import pytest from pathlib import Path from unittest.mock import patch, MagicMock class TestSMRFPipeline: def test_pipeline_json_valid(self): """create_smrf_pipeline produces valid JSON with expected stages.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/data/input/test.laz", "/data/output/test_ground.las") pipeline = json.loads(result) assert "pipeline" in pipeline stages = pipeline["pipeline"] # Should have: reader, range filter (ReturnNumber), assign, ELM, outlier, SMRF, range filter (Classification), writer stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # First stage is the filename string (reader) assert isinstance(stages[0], str) assert "test.laz" in stages[0] # Must contain preprocessing steps assert "filters.assign" in stage_types assert "filters.elm" in stage_types assert "filters.outlier" in stage_types # Must contain SMRF filter assert "filters.smrf" in stage_types # Must contain ReturnNumber filter range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert len(range_stages) >= 1 # At least one should filter ReturnNumber assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) def test_pipeline_elm_parameters(self): """ELM filter has terrain-adapted parameters.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) elm_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.elm"][0] assert elm_stage["cell"] == 5.0 assert elm_stage["threshold"] == 2.0 def test_pipeline_outlier_parameters(self): """Outlier filter uses statistical method.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) outlier_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.outlier"][0] assert outlier_stage["method"] == "statistical" assert outlier_stage["mean_k"] == 8 assert outlier_stage["multiplier"] == 3.0 def test_pipeline_output_path(self): """Pipeline output path is set correctly.""" from lidar_pipeline.dtm import create_smrf_pipeline result = create_smrf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) # Last stage should be writer with correct output path writer = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "writers.las"][0] assert writer["filename"] == "/output/a_ground.las" class TestCSFPipeline: def test_pipeline_json_valid(self): """create_csf_pipeline produces valid JSON with CSF filter.""" from lidar_pipeline.dtm import create_csf_pipeline result = create_csf_pipeline("/data/input/test.laz", "/data/output/test_ground.las") pipeline = json.loads(result) assert "pipeline" in pipeline stages = pipeline["pipeline"] stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # Must contain CSF filter assert "filters.csf" in stage_types # Must contain ReturnNumber filter range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) def test_csf_parameters(self): """CSF pipeline has expected parameters.""" from lidar_pipeline.dtm import create_csf_pipeline result = create_csf_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) csf_stage = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.csf"][0] assert csf_stage["resolution"] == 1.0 # cloth 1 m : ~4× plus rapide, MNT inchangé assert csf_stage["rigidness"] == 3 assert csf_stage["smooth"] is True assert "hdiff" not in csf_stage # hdiff is not a valid PDAL CSF parameter class TestInterpolateHoles: def test_fills_interior_hole_with_surface(self): """Large interior NaN hole is filled (no NaN left, value is plausible).""" from lidar_pipeline.dtm import _interpolate_holes # Linear-in-column surface z = 0.02 * x, with a large square hole in the middle. x = np.arange(40, dtype=float) * 0.02 dtm = np.tile(x, (40, 1)) dtm[16:24, 16:24] = np.nan filled, count = _interpolate_holes(dtm) assert count == 64 assert not np.isnan(filled).any() # Filled values stay within the surrounding z range (no wild extrapolation). zmin, zmax = np.nanmin(dtm), np.nanmax(dtm) hole_vals = filled[16:24, 16:24] assert np.all(hole_vals >= zmin - 1e-6) assert np.all(hole_vals <= zmax + 1e-6) # A linear surface is interpolated near-exactly in the interior. expected = np.tile(x[16:24], (8, 1)) assert np.allclose(hole_vals, expected, atol=0.02) # Original valid cells are untouched. valid = ~np.isnan(dtm) assert np.allclose(filled[valid], dtm[valid]) def test_no_holes_returns_unchanged(self): """No NaN → returns same array and zero count.""" from lidar_pipeline.dtm import _interpolate_holes dtm = np.arange(64, dtype=float).reshape(8, 8) filled, count = _interpolate_holes(dtm) assert count == 0 assert np.shares_memory(filled, dtm) def test_all_nan_returns_unchanged(self): """No valid data → cannot interpolate, returns zeros-free NaN array.""" from lidar_pipeline.dtm import _interpolate_holes dtm = np.full((8, 8), np.nan) filled, count = _interpolate_holes(dtm) assert count == 0 assert np.isnan(filled).all() class TestMinReturnGrid: def test_takes_lowest_return_per_cell(self, tmp_output_dir): """_min_return_grid rasterise le point le plus bas par cellule (pas la moyenne).""" import laspy from lidar_pipeline.dtm import _min_return_grid out = tmp_output_dir / "pts.las" hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) # Grille 2x2 sur [0,2]x[0,2]. La cellule (0,0) porte deux points z=5 et # z=2 (min=2, moyenne=3.5) ; (1,0) z=3 ; (0,1) z=4 ; (1,1) vide. las.x = [0.2, 0.5, 1.2, 0.3] las.y = [0.2, 0.3, 0.4, 1.5] las.z = [5.0, 2.0, 3.0, 4.0] las.write(str(out)) grid = _min_return_grid(out, 2, 2, (0.0, 0.0, 2.0, 2.0)) assert grid.shape == (2, 2) assert int(np.isnan(grid).sum()) == 1 # Une seule valeur par cellule, et la cellule (0,0) vaut le MIN (2.0). vals = sorted(float(v) for v in grid[~np.isnan(grid)]) assert vals == [2.0, 3.0, 4.0] assert 3.5 not in vals class TestBareEarth: """Le plancher « sol nu » ramène le DTM au retour le plus bas de chaque cellule.""" def _write_las(self, path, points): """points: list of (x, y, z). Écrit un LAS 1.2 format 0 aux bornes [0,2]x[0,2].""" import laspy hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) las.x = [p[0] for p in points] las.y = [p[1] for p in points] las.z = [p[2] for p in points] las.write(str(path)) return path def _make_clouds(self, tmp_output_dir): """Grille 2x2 (res=1.0). Les points « coin » à 0.05/1.95 imposent l'étendue [0.05,1.95] (laspy re-déduit les bornes de l'en-tête depuis les points). Le sol (las_file) vaut z=10 partout. Le nuage complet (source_laz) a un retour plus bas dans les cellules (0,0) -> 2 et (1,0) -> 5 ; les deux autres cellules n'ont que z=10.""" ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0), (1.5, 1.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)] source = list(ground) + [(0.3, 0.3, 2.0), (1.3, 0.3, 5.0)] las_file = self._write_las(tmp_output_dir / "ground.las", ground) source_laz = self._write_las(tmp_output_dir / "source.las", source) return las_file, source_laz def _dtm_values(self, tmp_output_dir, bare_earth): from lidar_pipeline.dtm import create_dtm_fast import rasterio las_file, source_laz = self._make_clouds(tmp_output_dir) out = create_dtm_fast(las_file, "tile", tmp_output_dir, 1.0, force=True, source_laz=source_laz, bare_earth=bare_earth) assert out is not None with rasterio.open(str(out)) as src: arr = src.read(1).astype("float64") return arr[~np.isnan(arr)] def test_bare_earth_pulls_dtm_to_lowest_return(self, tmp_output_dir): """Avec bare_earth, le DTM descend aux retours les plus bas (2 et 5).""" vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=True)) # Les cellules sans retour plus bas restent à 10 ; les deux autres descendent. assert vals == [2.0, 5.0, 10.0, 10.0] assert vals[0] == 2.0 and vals[1] == 5.0 def test_no_bare_earth_keeps_mean(self, tmp_output_dir): """Sans bare_earth, le DTM garde la moyenne des points sol (10 partout).""" vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=False)) assert all(v == 10.0 for v in vals) class TestDetectGroundMethod: def _make_mock_las(self, num_returns, z_values): """Create a mock laspy object with specified NumberOfReturns and z.""" mock_las = MagicMock() mock_las.NumberOfReturns = np.array(num_returns) mock_las.z = np.array(z_values) mock_las.points = MagicMock() mock_las.points.__len__ = lambda self: len(num_returns) return mock_las @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_urban_terrain_returns_csf(self, mock_read, mock_pdal): """High single-return ratio (>0.6) should select CSF.""" from lidar_pipeline.dtm import detect_ground_method # 70% single returns = urban n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.3)] = 2 # 30% multi-return z_values = np.random.normal(100, 5, n) # Low variance = flat terrain mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_natural_terrain_returns_smrf(self, mock_read, mock_pdal): """Low single-return ratio and moderate variance should select SMRF.""" from lidar_pipeline.dtm import detect_ground_method # 40% single returns, moderate variance n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.6)] = 2 # 60% multi-return (forest) z_values = np.random.normal(100, 15, n) # Moderate variance mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'smrf' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_mountainous_terrain_returns_csf(self, mock_read, mock_pdal): """High variance terrain (>30m std) selects CSF for complex terrain.""" from lidar_pipeline.dtm import detect_ground_method # Moderate single-return ratio but very high height variance n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.5)] = 2 z_values = np.random.normal(100, 50, n) # Very high variance = mountainous mock_read.return_value = self._make_mock_las(num_returns, z_values) result = detect_ground_method(Path("/data/input/test.laz")) assert result == 'csf' class TestIGNPipeline: def test_pipeline_keeps_supplier_classification(self): """create_ign_pipeline réutilise la pré-classification (classe 2) sans refiltrer.""" from lidar_pipeline.dtm import create_ign_pipeline result = create_ign_pipeline("/input/a.laz", "/output/a_ground.las") pipeline = json.loads(result) stages = pipeline["pipeline"] stage_types = [s.get("type") if isinstance(s, dict) else None for s in stages] # Aucun algorithme de classification, pas de remise à zéro, pas de filtres de bruit assert "filters.smrf" not in stage_types assert "filters.csf" not in stage_types assert "filters.assign" not in stage_types assert "filters.elm" not in stage_types assert "filters.outlier" not in stage_types # Filtre ReturnNumber conservé + extraction des points classe 2 range_stages = [s for s in stages if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("ReturnNumber" in str(s.get("limits", "")) for s in range_stages) assert any(s.get("limits") == "Classification[2:2]" for s in range_stages) writer = [s for s in stages if isinstance(s, dict) and s.get("type") == "writers.las"][0] assert writer["filename"] == "/output/a_ground.las" class TestDetectIGN: def _make_mock_las(self, classification, num_returns, z_values): mock_las = MagicMock() mock_las.classification = classification mock_las.NumberOfReturns = np.array(num_returns) mock_las.z = np.array(z_values) mock_las.points = MagicMock() mock_las.points.__len__ = lambda self: len(num_returns) return mock_las @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_preclassified_returns_ign(self, mock_read, mock_pdal): """Fichier pré-classifié (majorité classe 2) → méthode IGN.""" from lidar_pipeline.dtm import detect_ground_method n = 10000 num_returns = np.ones(n, dtype=int) cls = np.zeros(n, dtype=np.uint8) cls[int(n * 0.15):] = 2 # 85 % de points classe 2 z_values = np.random.normal(100, 5, n) mock_read.return_value = self._make_mock_las(cls, num_returns, z_values) assert detect_ground_method(Path("/data/input/test.laz")) == 'ign' @patch('lidar_pipeline.dtm._read_with_pdal') @patch('laspy.read') def test_unclassified_falls_back_to_smrf_or_csf(self, mock_read, mock_pdal): """Sans classification exploitable → détection SMRF/CSF classique.""" from lidar_pipeline.dtm import detect_ground_method n = 10000 num_returns = np.ones(n, dtype=int) num_returns[:int(n * 0.6)] = 2 # 60 % multi-retours (forêt) → non urbain cls = np.zeros(n, dtype=np.uint8) # aucun point classe 2 z_values = np.random.normal(100, 5, n) mock_read.return_value = self._make_mock_las(cls, num_returns, z_values) assert detect_ground_method(Path("/data/input/test.laz")) == 'smrf' class TestClassifyGroundMethod: @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_auto_calls_detect(self, mock_subprocess): """classify_ground with method='auto' should call detect_ground_method.""" from lidar_pipeline.dtm import classify_ground # Mock detect_ground_method to return 'csf' with patch('lidar_pipeline.dtm.detect_ground_method', return_value='csf') as mock_detect: mock_subprocess.run.return_value = MagicMock(returncode=0) result = classify_ground(Path("/data/input/test.laz"), Path("/tmp"), method='auto') mock_detect.assert_called_once() @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_subprocess): """classify_ground with method='smrf' should create SMRF pipeline.""" from lidar_pipeline.dtm import classify_ground, _create_ground_pipeline mock_subprocess.run.return_value = MagicMock(returncode=0) with patch('lidar_pipeline.dtm.detect_ground_method'): # Create temp dir import tempfile with tempfile.TemporaryDirectory() as tmpdir: result = classify_ground(Path("/data/input/test.laz"), Path(tmpdir), method='smrf') # Check the pipeline JSON was written with SMRF pipeline_file = Path(tmpdir) / "pipeline_smrf.json" if pipeline_file.exists(): pipeline = json.loads(pipeline_file.read_text()) stage_types = [s.get("type") if isinstance(s, dict) else None for s in pipeline["pipeline"]] assert "filters.smrf" in stage_types @patch('lidar_pipeline.dtm.subprocess') def test_classify_ground_csf_uses_csf_pipeline(self, mock_subprocess): """classify_ground with method='csf' should create CSF pipeline.""" from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with patch('lidar_pipeline.dtm.detect_ground_method'): import tempfile with tempfile.TemporaryDirectory() as tmpdir: result = classify_ground(Path("/data/input/test.laz"), Path(tmpdir), method='csf') pipeline_file = Path(tmpdir) / "pipeline_csf.json" if pipeline_file.exists(): pipeline = json.loads(pipeline_file.read_text()) stage_types = [s.get("type") if isinstance(s, dict) else None for s in pipeline["pipeline"]] assert "filters.csf" in stage_types class TestParseIgnClasses: def test_default_sol(self): """'sol' → code 2 seul.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("sol") == [2] def test_names_sorted_dedup(self): """Noms acceptés (EN/FR), triés et dédupliqués.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("sol,unclassified") == [1, 2] assert parse_ign_classes("unclassified,sol") == [1, 2] assert parse_ign_classes("non-classe") == [1] assert parse_ign_classes("sol,2") == [2] def test_numeric_codes(self): """Codes LAS directs, triés.""" from lidar_pipeline.dtm import parse_ign_classes assert parse_ign_classes("2,1") == [1, 2] assert parse_ign_classes("66") == [66] def test_invalid_raises(self): """Nom inconnu, code hors bornes ou liste vide → ValueError.""" from lidar_pipeline.dtm import parse_ign_classes with pytest.raises(ValueError): parse_ign_classes("foo") with pytest.raises(ValueError): parse_ign_classes("300") with pytest.raises(ValueError): parse_ign_classes("") def test_method_label(self): """'ign' seul pour le sol, combinaison encodée sinon (invalidation cache).""" from lidar_pipeline.dtm import ign_method_label assert ign_method_label([2]) == "ign" assert ign_method_label([1, 2]) == "ign_1_2" assert ign_method_label([2, 1]) == "ign_1_2" class TestIGNPipelineMultiClasses: def test_multi_class_limits(self): """Plusieurs classes → plages OU logiques sur Classification.""" from lidar_pipeline.dtm import _create_ground_pipeline result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign', ign_codes=[1, 2]) pipeline = json.loads(result) range_stages = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] limits = [str(s.get("limits", "")) for s in range_stages] assert any("Classification[1:1]" in l and "Classification[2:2]" in l for l in limits) def test_default_sol_only(self): """Sans ign_codes, la voie IGN reste sol seul (2) — rétrocompatible.""" from lidar_pipeline.dtm import _create_ground_pipeline result = _create_ground_pipeline("/input/a.laz", "/output/a_ground.las", 'ign') pipeline = json.loads(result) range_stages = [s for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] limits = [str(s.get("limits", "")) for s in range_stages] assert any("Classification[2:2]" in l and "Classification[1:1]" not in l for l in limits) class TestClassifyGroundIgnClasses: @patch('lidar_pipeline.dtm.subprocess') def test_ign_classes_encoded_in_filenames(self, mock_subprocess): """--ign-classes sol,unclassified → fichiers ign_1_2 + filtre multi-classes.""" import tempfile from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) classify_ground(Path("/data/input/test.laz"), tmpdir, method='ign', ign_classes="sol,unclassified") pipeline_file = tmpdir / "pipeline_ign_1_2.json" assert pipeline_file.exists() pipeline = json.loads(pipeline_file.read_text()) limits = [str(s.get("limits", "")) for s in pipeline["pipeline"] if isinstance(s, dict) and s.get("type") == "filters.range"] assert any("Classification[1:1]" in l and "Classification[2:2]" in l for l in limits) @patch('lidar_pipeline.dtm.subprocess') def test_ign_default_label_unchanged(self, mock_subprocess): """--ign-classes sol (défaut) → noms 'ign' inchangés (cache préservé).""" import tempfile from lidar_pipeline.dtm import classify_ground mock_subprocess.run.return_value = MagicMock(returncode=0) with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) classify_ground(Path("/data/input/test.laz"), tmpdir, method='ign') assert (tmpdir / "pipeline_ign.json").exists() assert not (tmpdir / "pipeline_ign_1_2.json").exists() class TestPureDtm: """Mode pur (classification IGN) : aucune retouche, trous en nodata.""" def _write_las(self, path, points): import laspy hdr = laspy.LasHeader(version='1.2', point_format=0) las = laspy.LasData(hdr) las.x = [p[0] for p in points] las.y = [p[1] for p in points] las.z = [p[2] for p in points] las.write(str(path)) return path def _make_clouds(self, tmp_output_dir): """Grille 2x2 (res=1.0). Sol sur 3 cellules (z=10), trou en (1,1). Le nuage complet a un retour plus bas (z=7) dans le trou.""" corners = [(0.05, 0.05, 10.0), (1.95, 0.05, 10.0), (0.05, 1.95, 10.0)] ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0)] + corners source = list(ground) + [(1.5, 1.5, 7.0)] las_file = self._write_las(tmp_output_dir / "ground_pure.las", ground) source_laz = self._write_las(tmp_output_dir / "source_pure.las", source) return las_file, source_laz def _dtm_array(self, tmp_output_dir, pure): from lidar_pipeline.dtm import create_dtm_fast import rasterio las_file, source_laz = self._make_clouds(tmp_output_dir) out = create_dtm_fast(las_file, "tile_pure", tmp_output_dir, 1.0, force=True, source_laz=source_laz, pure=pure) assert out is not None with rasterio.open(str(out)) as src: return src.read(1).astype("float64") def test_pure_fills_holes_without_floor(self, tmp_output_dir): """pur=True : trous comblés par interpolation, sans plancher à 7. Le comblement est actif dans tous les modes (comportement historique) ; « pur » ne désactive que l'abaissement au retour le plus bas. """ arr = self._dtm_array(tmp_output_dir, pure=True) assert int(np.isnan(arr).sum()) == 0 vals = sorted(float(v) for v in arr.flatten()) assert vals == [10.0, 10.0, 10.0, 10.0] def test_not_pure_fills_holes(self, tmp_output_dir): """pur=False : le trou est comblé (comportement historique conservé).""" arr = self._dtm_array(tmp_output_dir, pure=False) assert int(np.isnan(arr).sum()) == 0 vals = sorted(float(v) for v in arr.flatten()) assert len(vals) == 4 assert vals[-1] == 10.0 class TestStripLidarExt: def test_copc_laz(self): from lidar_pipeline.dtm import _strip_lidar_ext assert _strip_lidar_ext("LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc.laz") == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" def test_laz(self): from lidar_pipeline.dtm import _strip_lidar_ext assert _strip_lidar_ext("file.laz") == "file" def test_las(self): from lidar_pipeline.dtm import _strip_lidar_ext assert _strip_lidar_ext("file.las") == "file" def test_path_object(self): from lidar_pipeline.dtm import _strip_lidar_ext from pathlib import Path assert _strip_lidar_ext(Path("/data/input/file.copc.laz")) == "file" class TestStripVerticalOffsets: """Vertical de-bias of flight-line point sources (strip alignment).""" def _synthetic(self, offsets, n=1_500_000, extent=300.0, seed=0): """Interleaved sources over one tile; each carries a known Z bias.""" rng = np.random.default_rng(seed) x = rng.uniform(0, extent, n) y = rng.uniform(0, extent, n) z = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n) psid = rng.integers(0, len(offsets), n) return x, y, z + np.asarray(offsets)[psid], psid.astype(np.uint16) def test_recovers_known_offsets(self): """±5 cm biases are recovered; the aligned source stays untouched.""" from lidar_pipeline.dtm import _strip_vertical_offsets x, y, z, psid = self._synthetic((0.0, 0.05, -0.05)) offs = _strip_vertical_offsets(x, y, z, psid) assert abs(offs.get(1, 0.0) - 0.05) < 0.01 assert abs(offs.get(2, 0.0) + 0.05) < 0.01 assert 0 not in offs # biais ~0 < seuil : pas de correction def test_small_bias_below_threshold_ignored(self): """Biases under the 0.5 cm threshold trigger no correction.""" from lidar_pipeline.dtm import _strip_vertical_offsets x, y, z, psid = self._synthetic((0.0, 0.003, -0.003)) assert _strip_vertical_offsets(x, y, z, psid) == {} def test_single_source_returns_empty(self): """A single point source cannot be compared: no offsets.""" from lidar_pipeline.dtm import _strip_vertical_offsets x, y, z, psid = self._synthetic((0.05,)) assert _strip_vertical_offsets(x, y, z, psid) == {} def test_no_shared_cells_returns_empty(self): """Sources covering disjoint areas (no overlap) are not corrected.""" from lidar_pipeline.dtm import _strip_vertical_offsets rng = np.random.default_rng(1) n = 200_000 x = np.concatenate([rng.uniform(0, 100, n), rng.uniform(200, 300, n)]) y = rng.uniform(0, 300, 2 * n) z = 100 + rng.normal(0, 0.01, 2 * n) psid = np.concatenate([np.zeros(n, np.uint16), np.ones(n, np.uint16)]) z[psid == 1] += 0.10 assert _strip_vertical_offsets(x, y, z, psid) == {} class TestStripAlignSidecar: def test_sidecar_roundtrip_and_threshold(self, tmp_path): """Sidecar records version/threshold/offsets and matches config.""" from lidar_pipeline.dtm import ( _write_strip_align_sidecar, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD) import json offsets = {1049: 0.026, 1147: -0.026} _write_strip_align_sidecar(tmp_path, "TILE", "_r0p2", offsets) data = json.loads((tmp_path / "TILE_dtm_r0p2_stripalign.json").read_text()) assert data["version"] == STRIP_ALIGN_VERSION assert data["threshold"] == STRIP_ALIGN_THRESHOLD assert data["offsets"] == {"1049": 0.026, "1147": -0.026} # clés JSON en chaînes def test_pipeline_match_logic(self, tmp_path): """_strip_align_matches invalidates legacy DTMs and config changes.""" from lidar_pipeline.pipeline import LidarArchaeoPipeline from lidar_pipeline.dtm import _write_strip_align_sidecar import json class P(LidarArchaeoPipeline): def __init__(self, out, strip_align): self.output_dir = out self.dtm_dir = out / "DTM" self.dtm_dir.mkdir(exist_ok=True) self.strip_align = strip_align p = P(tmp_path, strip_align=True) # DTM hérité sans sidecar : à régénérer assert not p._strip_align_matches("TILE", "_r0p2") # Sidecar conforme : valide _write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {}) assert p._strip_align_matches("TILE", "_r0p2") # Seuil différent : à régénérer bad = p.dtm_dir / "TILE_dtm_r0p2_stripalign.json" bad.write_text(json.dumps({"version": 1, "threshold": 0.02, "offsets": {}})) assert not p._strip_align_matches("TILE", "_r0p2") # Calage désactivé + DTM calé : à régénérer assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")