import json from pathlib import Path from unittest.mock import Mock from urllib.parse import parse_qs, urlparse from xml.etree import ElementTree as ET import numpy as np import pytest import rasterio from pyproj import Transformer from rasterio.io import MemoryFile from rasterio.transform import from_bounds from shapely.geometry import box from flight_map.ahn import SIZE, coverage_url, terrain_data from flight_map.geo import DEFAULT_BOUNDS, Bounds from flight_map.maps import water_areas from flight_map.network import request_bytes from flight_map.plot import SVG_NS, render from flight_map.storage import ( Observation, checkpoint, create_session, database, initialize, save_observations, ) from flight_map.terrain import Elevation BOUNDS = DEFAULT_BOUNDS def ahn_bytes(value: float = -3) -> bytes: bounds = Transformer.from_crs(4326, 28992, always_xy=True).transform_bounds(*BOUNDS.values) with MemoryFile() as memory: with memory.open( driver="GTiff", width=SIZE, height=SIZE, count=1, dtype="float32", crs="EPSG:28992", transform=from_bounds(*bounds, SIZE, SIZE), nodata=-9999, ) as dataset: dataset.write(np.full((SIZE, SIZE), value, dtype="float32"), 1) return memory.read() def test_pdok_request_cache_refresh_and_metadata( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: fetch = Mock(return_value=ahn_bytes()) monkeypatch.setattr("flight_map.ahn.request_bytes", fetch) path = terrain_data(BOUNDS, tmp_path) assert path is not None assert terrain_data(BOUNDS, tmp_path) == path fetch.assert_called_once() params = parse_qs(urlparse(fetch.call_args.args[0]).query) assert params["coverage"] == ["dtm_05m"] assert params["crs"] == ["EPSG:28992"] assert params["format"] == ["GEOTIFF"] assert params["width"] == params["height"] == [str(SIZE)] with rasterio.open(path) as dataset: assert dataset.tags()["vertical_reference"] == "NAP" assert dataset.tags()["license"] == "CC0" assert terrain_data(BOUNDS, tmp_path, refresh=True) == path assert fetch.call_count == 2 assert coverage_url(BOUNDS) != coverage_url(Bounds(4.7, 52.2, 4.8, 52.3)) before = path.read_bytes() fetch.side_effect = RuntimeError("HTTP 503") assert terrain_data(BOUNDS, tmp_path, refresh=True) is None assert path.read_bytes() == before assert len(list(tmp_path.glob("*.tif"))) == 1 def test_unavailable_corrupt_and_outside_coverage_warn( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: fetch = Mock(return_value=b"no coverage") monkeypatch.setattr("flight_map.ahn.request_bytes", fetch) assert terrain_data(BOUNDS, tmp_path) is None assert "height map unavailable" in caplog.text assert not list(tmp_path.iterdir()) fetch.reset_mock() assert terrain_data(Bounds(-1, 50, 0, 51), tmp_path) is None fetch.assert_not_called() assert "outside Dutch AHN coverage" in caplog.text fetch.return_value = ahn_bytes() path = terrain_data(BOUNDS, tmp_path) assert path is not None path.write_bytes(b"broken") assert terrain_data(BOUNDS, tmp_path) is None assert "refresh-terrain" in caplog.text assert terrain_data(BOUNDS, tmp_path, refresh=True) == path def test_failed_cache_replace_keeps_old_height_map( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr("flight_map.ahn.request_bytes", Mock(return_value=ahn_bytes())) path = terrain_data(BOUNDS, tmp_path) assert path is not None before = path.read_bytes() monkeypatch.setattr("flight_map.ahn.os.replace", Mock(side_effect=OSError("disk error"))) assert terrain_data(BOUNDS, tmp_path, refresh=True) is None assert path.read_bytes() == before assert list(tmp_path.iterdir()) == [path] def water_way( points: list[tuple[float, float]], identifier: int = 1, ) -> dict[str, object]: return { "type": "way", "id": identifier, "tags": {"natural": "water"}, "geometry": [{"lon": x, "lat": y} for x, y in points], } def test_water_polygon_and_relation_preserve_islands() -> None: outer = [(0, 0), (4, 0), (4, 4), (0, 4), (0, 0)] inner = [(1, 1), (3, 1), (3, 3), (1, 3), (1, 1)] members = [ {"type": "way", "ref": 1, "role": "outer", "geometry": water_way(outer)["geometry"]}, {"type": "way", "ref": 2, "role": "inner", "geometry": water_way(inner)["geometry"]}, ] relation = { "type": "relation", "tags": {"natural": "water", "type": "multipolygon"}, "members": members, } geometry = water_areas({"elements": [water_way(outer), relation]}) assert geometry.area == 12 assert not geometry.covers(box(1.5, 1.5, 2.5, 2.5)) assert water_areas({"elements": [water_way(outer)]}).area == 16 # Relations often consist of several open ways joined into an outer ring. members[0]["geometry"] = water_way(outer[:3])["geometry"] members.append( {"type": "way", "ref": 3, "role": "outer", "geometry": water_way(outer[2:])["geometry"]} ) assert water_areas({"elements": [relation]}).area == 12 assert water_areas({"elements": []}).is_empty with pytest.raises(ValueError, match="unclosed"): water_areas({"elements": [water_way(outer[:3])]}) members.pop() with pytest.raises(ValueError, match="incomplete"): water_areas({"elements": [relation]}) def test_land_mean_excludes_water_but_reports_missing_land( tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: path = tmp_path / "terrain.tif" bounds = Bounds(0, 0, 4, 4) values = np.full((4, 4), 100, dtype="float32") values[:, :2] = -50 # These are valid water elevations, which must not enter the mean. values[0, 2] = -9999 # Missing land is a separate coverage issue. with rasterio.open( path, "w", driver="GTiff", width=4, height=4, count=1, dtype="float32", crs="EPSG:4326", transform=from_bounds(*bounds.values, 4, 4), nodata=-9999, ) as dataset: dataset.write(values, 1) with Elevation(path, "nap") as elevation: assert elevation.mean_ground(bounds, 4, water=box(0, 0, 2, 4)) == 100 assert elevation.water_samples == 8 assert elevation.land_samples == 8 assert elevation.valid_land_samples == 7 assert elevation.land_coverage == 7 / 8 assert "87.5%" in caplog.text assert "Missing land is not treated as water" in caplog.text assert elevation.mean_ground(bounds, 4, water=box(0, 0, 4, 4)) is None def test_plot_auto_ahn_and_warning_fallback( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: db = tmp_path / "flights.sqlite" with database(db) as connection: initialize(connection) session = create_session(connection, BOUNDS, 2000, 30, 100) save_observations( connection, session, [ Observation("abc123", "TEST", 110, 4.79, 52.24, 900, 1043), Observation("abc123", "TEST", 140, 4.85, 52.24, 900, 1043), ], 150, ) checkpoint(connection, session.id, 150, "stopped") local = tmp_path / "map.json" local.write_text( json.dumps( { "elements": [ { "type": "way", "geometry": [{"lon": 4.79, "lat": 52.24}, {"lon": 4.85, "lat": 52.24}], }, ] } ) ) monkeypatch.setattr("flight_map.ahn.request_bytes", Mock(return_value=ahn_bytes(-3))) output = tmp_path / "map.svg" assert render(db, session.id, output, local_map=local, cache=tmp_path / "cache") == 1 text = output.read_text() assert "PDOK AHN DTM" in text and "Terrain reference: nap" in text root = ET.parse(output).getroot() flight = root.find(f".//{{{SVG_NS}}}g[@id='flight-1']") assert flight is not None assert flight.attrib["data-average-barometric-altitude-metres"] == "900.000" assert "altitude(s) unavailable" not in caplog.text assert "ground elevation in metres: -3.0" in text monkeypatch.setattr("flight_map.ahn.request_bytes", Mock(return_value=ahn_bytes(100))) changed_output = tmp_path / "changed-ground.svg" assert ( render( db, session.id, changed_output, local_map=local, cache=tmp_path / "cache", refresh_terrain=True, ) == 1 ) changed_flight = ET.parse(changed_output).getroot().find(f".//{{{SVG_NS}}}g[@id='flight-1']") assert changed_flight is not None assert changed_flight.attrib["data-average-barometric-altitude-metres"] == "900.000" assert "ground elevation in metres: 100.0" in changed_output.read_text() monkeypatch.setattr("flight_map.ahn.request_bytes", Mock(side_effect=RuntimeError("offline"))) assert ( render( db, session.id, tmp_path / "unavailable.svg", local_map=local, cache=tmp_path / "other-cache", ) == 1 ) assert "AHN height map unavailable" in caplog.text assert "Average land elevation unavailable" in caplog.text unavailable = ET.parse(tmp_path / "unavailable.svg").getroot() unchanged = unavailable.find(f".//{{{SVG_NS}}}g[@id='flight-1']") assert unchanged is not None assert unchanged.attrib["data-average-barometric-altitude-metres"] == "900.000" invalid_water = json.loads(local.read_text()) invalid_water["elements"].append(water_way([(4.80, 52.24), (4.81, 52.25)])) local.write_text(json.dumps(invalid_water)) assert render(db, session.id, tmp_path / "bad-water.svg", local_map=local, no_terrain=True) == 1 assert "unclosed outline" in caplog.text assert "Reported flight altitudes are unaffected" in caplog.text downloader = Mock(side_effect=AssertionError("must stay offline")) monkeypatch.setattr("flight_map.plot.terrain_data", downloader) assert render(db, session.id, tmp_path / "disabled.svg", local_map=local, no_terrain=True) == 1 downloader.assert_not_called() with database(db) as connection: with connection: connection.execute("UPDATE observations SET baro_altitude = NULL") missing_output = tmp_path / "missing-baro.svg" assert render(db, session.id, missing_output, local_map=local, no_terrain=True) == 1 missing_flight = ET.parse(missing_output).getroot().find(f".//{{{SVG_NS}}}g[@id='flight-1']") assert missing_flight is not None assert missing_flight.attrib["data-average-barometric-altitude-metres"] == "unavailable" assert "reported barometric altitude is missing" in caplog.text assert "no geometric-altitude fallback" in caplog.text with pytest.raises(ValueError, match="cannot be combined"): render(db, session.id, tmp_path / "bad.svg", no_terrain=True, refresh_terrain=True) def test_binary_download_errors_and_size_limit(monkeypatch: pytest.MonkeyPatch) -> None: from urllib.error import URLError response = Mock() response.__enter__ = Mock(return_value=response) response.__exit__ = Mock(return_value=False) response.read.return_value = b"12345" fetch = Mock(return_value=response) monkeypatch.setattr("flight_map.network.urlopen", fetch) assert request_bytes("https://example.invalid") == b"12345" with pytest.raises(RuntimeError, match="limit"): request_bytes("https://example.invalid", max_bytes=4) fetch.side_effect = URLError("offline") with pytest.raises(RuntimeError, match="download failed"): request_bytes("https://example.invalid")