Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
8.9 KiB
Python
238 lines
8.9 KiB
Python
"""Tests for IGN LiDAR HD tile downloads (fetch_ign)."""
|
|
|
|
|
|
def test_parse_tile_specs():
|
|
"""Accepts 'col,row', 'col:row' and ignores whitespace."""
|
|
from lidar_pipeline.fetch_ign import parse_tile_specs
|
|
assert parse_tile_specs(["1055,6882"]) == [(1055, 6882)]
|
|
assert parse_tile_specs(["1055:6883", " 651 , 6630 "]) == [(1055, 6883), (651, 6630)]
|
|
|
|
|
|
def test_parse_tile_specs_rejects_invalid():
|
|
"""Malformed specifications raise an explicit error."""
|
|
import pytest
|
|
from lidar_pipeline.fetch_ign import parse_tile_specs
|
|
with pytest.raises(ValueError):
|
|
parse_tile_specs(["1055"])
|
|
with pytest.raises(ValueError):
|
|
parse_tile_specs(["abc,def"])
|
|
with pytest.raises(ValueError):
|
|
parse_tile_specs(["1055,6882,9999"])
|
|
|
|
|
|
def test_tile_filename_pads_coordinates():
|
|
"""The tile file name uses 4-digit coordinates."""
|
|
from lidar_pipeline.fetch_ign import tile_filename
|
|
assert tile_filename(1055, 6882) == "LHD_FXX_1055_6882_PTS_LAMB93_IGN69.copc.laz"
|
|
assert tile_filename(651, 6630) == "LHD_FXX_0651_6630_PTS_LAMB93_IGN69.copc.laz"
|
|
|
|
|
|
def test_match_feature_uses_coordonnees_nw():
|
|
"""Matching uses lidarhd:coordonnees_NW (0816-6847 format)."""
|
|
from lidar_pipeline.fetch_ign import match_feature
|
|
features = [
|
|
{"id": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_NE",
|
|
"properties": {"lidarhd:coordonnees_NW": "1054-6882"},
|
|
"assets": {"data": {"href": "https://example.org/a.copc.laz"}}},
|
|
{"id": "LHD_FXX_1055_6882_PTS_LAMB93_IGN69_NE",
|
|
"properties": {"lidarhd:coordonnees_NW": "1055-6882"},
|
|
"assets": {"data": {"href": "https://example.org/b.copc.laz"}}},
|
|
]
|
|
assert match_feature(features, 1055, 6882) is features[1]
|
|
assert match_feature(features, 9999, 9999) is None
|
|
|
|
|
|
def test_find_tile_url_matches_and_returns_href(monkeypatch):
|
|
"""find_tile_url queries the STAC API and returns the data asset href."""
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
class FakeResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload.encode("utf-8")
|
|
|
|
def read(self):
|
|
return self._payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
payload = ('{"features": [{"properties": {"lidarhd:coordonnees_NW": "1055-6882"},'
|
|
'"assets": {"data": {"href": "https://data.geopf.fr/x.copc.laz"}}}]}')
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
return FakeResponse(payload)
|
|
|
|
monkeypatch.setattr(fetch_ign.urllib.request, "urlopen", fake_urlopen)
|
|
url = fetch_ign.find_tile_url(1055, 6882)
|
|
assert url == "https://data.geopf.fr/x.copc.laz"
|
|
assert "api.stac.teledetection.fr" in captured["url"]
|
|
assert "bbox=" in captured["url"]
|
|
|
|
|
|
def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
|
|
"""No download if the LAZ exists or if visualisations exist."""
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
output_dir = tmp_path / "output"
|
|
vis_dir = output_dir / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
|
|
vis_dir.mkdir(parents=True)
|
|
|
|
# 1054,6882: visualisations already generated
|
|
# 1055,6882: LAZ already present
|
|
existing = input_dir / fetch_ign.tile_filename(1055, 6882)
|
|
existing.write_bytes(b"naze")
|
|
|
|
def fail_download(*args, **kwargs):
|
|
raise AssertionError("must not be called")
|
|
|
|
monkeypatch.setattr(fetch_ign, "find_tile_url", fail_download)
|
|
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
|
|
output_dir=output_dir)
|
|
assert result == []
|
|
|
|
|
|
def _make_viz_dirs(output_dir, col, row, viz_keys, resolutions=(0.5, 0.2)):
|
|
"""Create a tile's visualisation directories (one per resolution).
|
|
|
|
The resolution suffix is only on the DIRECTORY: files keep the bare
|
|
basename (see _expected_output_path in the pipeline).
|
|
"""
|
|
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
|
|
for res in resolutions:
|
|
suffix = "" if res == 0.5 else "_r" + str(res).replace('.', 'p')
|
|
vis = output_dir / "visualisations" / (base + suffix)
|
|
vis.mkdir(parents=True, exist_ok=True)
|
|
for key in viz_keys:
|
|
(vis / f"{base}_{key}.avif").write_bytes(b"x")
|
|
|
|
|
|
def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch):
|
|
"""An incomplete tile (missing visualisation) is downloaded, a complete one is not."""
|
|
from pathlib import Path
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
output_dir = tmp_path / "output"
|
|
_make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope missing
|
|
_make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complete
|
|
|
|
calls = []
|
|
|
|
def fake_find_tile_url(col, row, timeout=20):
|
|
calls.append((col, row))
|
|
return "https://example.org/tile.copc.laz"
|
|
|
|
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
|
|
Path(dest).write_bytes(b"laz")
|
|
return 3
|
|
|
|
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
|
|
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
|
|
|
|
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
|
|
output_dir=output_dir,
|
|
only_viz=["aspect", "slope"],
|
|
resolutions=(0.5, 0.2))
|
|
# Only the incomplete tile is downloaded again
|
|
assert calls == [(1054, 6882)]
|
|
assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)]
|
|
|
|
|
|
def test_fetch_tiles_promotes_edge_neighbor_copy(tmp_path, monkeypatch):
|
|
"""A tile already downloaded as a neighbour is moved to input/ without network access."""
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
edge_dir = input_dir / "edge_neighbors"
|
|
edge_dir.mkdir()
|
|
name = fetch_ign.tile_filename(1055, 6882)
|
|
neighbor_copy = edge_dir / name
|
|
neighbor_copy.write_bytes(b"laz-voisine")
|
|
|
|
def fail(*args, **kwargs):
|
|
raise AssertionError("must not be called (no network expected)")
|
|
|
|
monkeypatch.setattr(fetch_ign, "find_tile_url", fail)
|
|
monkeypatch.setattr(fetch_ign, "download_file", fail)
|
|
|
|
result = fetch_ign.fetch_tiles(input_dir, [(1055, 6882)])
|
|
|
|
assert not neighbor_copy.exists()
|
|
assert (input_dir / name).read_bytes() == b"laz-voisine"
|
|
assert result == [] # promotion = "skip", like the already-present case
|
|
|
|
|
|
def test_fetch_tiles_does_not_promote_when_called_on_edge_dir(tmp_path, monkeypatch):
|
|
"""Call with input_dir=edge_dir (_fetch_edge_neighbors case): never promotes."""
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
edge_dir = tmp_path / "input" / "edge_neighbors"
|
|
edge_dir.mkdir(parents=True)
|
|
inner_edge = edge_dir / "edge_neighbors"
|
|
name = fetch_ign.tile_filename(1055, 6882)
|
|
# Even if an edge_neighbors/edge_neighbors/<name> directory existed by
|
|
# accident, it must never be moved to edge_dir/<name>.
|
|
inner_edge.mkdir()
|
|
(inner_edge / name).write_bytes(b"must-not-move")
|
|
|
|
calls = []
|
|
|
|
def fake_find_tile_url(col, row, timeout=20):
|
|
calls.append((col, row))
|
|
return "https://example.org/tile.copc.laz"
|
|
|
|
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
|
|
from pathlib import Path
|
|
Path(dest).write_bytes(b"laz")
|
|
return 3
|
|
|
|
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
|
|
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
|
|
|
|
result = fetch_ign.fetch_tiles(edge_dir, [(1055, 6882)])
|
|
|
|
assert calls == [(1055, 6882)] # downloaded normally, not promoted
|
|
assert (inner_edge / name).read_bytes() == b"must-not-move"
|
|
assert (edge_dir / name).read_bytes() == b"laz"
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch):
|
|
"""force=True downloads even a complete tile (regeneration with --force)."""
|
|
from pathlib import Path
|
|
from lidar_pipeline import fetch_ign
|
|
|
|
input_dir = tmp_path / "input"
|
|
input_dir.mkdir()
|
|
output_dir = tmp_path / "output"
|
|
_make_viz_dirs(output_dir, 1054, 6882, ["aspect", "slope"])
|
|
|
|
calls = []
|
|
|
|
def fake_find_tile_url(col, row, timeout=20):
|
|
calls.append((col, row))
|
|
return "https://example.org/tile.copc.laz"
|
|
|
|
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
|
|
Path(dest).write_bytes(b"laz")
|
|
return 3
|
|
|
|
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
|
|
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
|
|
|
|
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882)],
|
|
output_dir=output_dir,
|
|
only_viz=["aspect", "slope"],
|
|
resolutions=(0.5, 0.2), force=True)
|
|
assert calls == [(1054, 6882)]
|
|
assert len(result) == 1
|