from __future__ import annotations from pathlib import Path from types import SimpleNamespace import pytest import rasterio from app.core import coverage as coverage_core from app.core.antenna import AntennaPattern, gain from app.core.coverage import compute_coverage from app.routers import jobs as jobs_router from app.core.surface import SurfaceProfile, SurfaceSample from app.core.vegetation import WORLDCOVER_P833, p833_coefficients_for_class from app.models.coverage import CoverageRequest from app.services import jobs def test_jobs_store_roundtrip() -> None: job_id = jobs.create_job("coverage", {"model": "fspl"}) jobs.update_job(job_id, status="running") jobs.update_job(job_id, status="done", result={"ok": True}) response = jobs.get_job(job_id) assert response is not None assert response.status == "done" assert response.result == {"ok": True} def test_job_artifact_path_supports_nested_coverage_uri( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: artifact = tmp_path / "coverage.png" artifact.write_bytes(b"png") job_id = jobs.create_job("coverage", {"model": "fspl"}) jobs.update_job( job_id, status="done", result={ "kind": "coverage", "data": {"kind": "coverage_raster", "uri": str(artifact)}, }, ) monkeypatch.setattr( jobs_router, "get_settings", lambda: SimpleNamespace(jobs_output_path=tmp_path), ) assert jobs_router._artifact_path(job_id) == artifact.resolve() def test_coverage_fspl_geojson() -> None: request = _coverage_request(format="geojson", radius_m=5000, range_step_m=500) result = compute_coverage(request) assert result["type"] == "FeatureCollection" assert len(result["features"]) >= 1 def test_coverage_geotiff_export(tmp_path: Path) -> None: request = _coverage_request(format="geotiff") result = compute_coverage(request, output_dir=tmp_path) path = Path(result["uri"]) assert path.exists() assert result["kind"] == "coverage_raster" assert result["format"] == "geotiff" assert result["metadata"]["value_units"] == "dBm" assert result["metadata"]["valid_pixels"] > 0 with rasterio.open(path) as dataset: assert dataset.driver == "GTiff" assert dataset.count == 1 assert dataset.crs is not None assert dataset.nodata == -9999.0 data = dataset.read(1) assert data.shape == (2, 2) assert (data != -9999.0).any() def test_coverage_png_export(tmp_path: Path) -> None: request = _coverage_request(format="png") result = compute_coverage(request, output_dir=tmp_path) path = Path(result["uri"]) assert path.exists() assert result["kind"] == "coverage_raster" assert result["format"] == "png" assert result["metadata"]["png_min_dbm"] is not None with rasterio.open(path) as dataset: assert dataset.driver == "PNG" assert dataset.count == 1 assert dataset.read(1).shape == (2, 2) def test_coverage_surface_obstruction_reduces_rx_power(monkeypatch) -> None: def fake_surface_profile(points, *, include_buildings, include_canopy, db=None): midpoint = len(points) // 2 samples = [ SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=0.0, building_m=0.0, canopy_m=80.0 if include_canopy and index == midpoint else 0.0, surface_m=80.0 if include_canopy and index == midpoint else 0.0, ) for index, point in enumerate(points) ] return SurfaceProfile(distance_m=points[-1].distance_m, samples=samples) monkeypatch.setattr(coverage_core, "surface_profile_from_points", fake_surface_profile) obstructed = _coverage_request( format="geojson", include_buildings=False, include_canopy=True, ) clear = _coverage_request( format="geojson", include_buildings=False, include_canopy=False, ) obstructed_power = coverage_core._rx_power_dbm(obstructed, 1.0, 90.0) clear_power = coverage_core._rx_power_dbm(clear, 1.0, 90.0) assert obstructed_power < clear_power def _coverage_request( *, format: str, radius_m: float = 1000, range_step_m: float = 1000, include_buildings: bool = False, include_canopy: bool = False, ) -> CoverageRequest: return CoverageRequest( tx={ "lat": 59.935, "lon": 30.305, "height_agl": 30, "power_dbm": 40, "frequency_mhz": 433, }, antenna={"pattern": "omni", "gain_dbi": 8}, rx={"height_agl": 2, "sensitivity_dbm": -110, "gain_dbi": 2}, model="fspl", radius_m=radius_m, azimuth_step_deg=90, range_step_m=range_step_m, include_buildings=include_buildings, include_canopy=include_canopy, include_vegetation=False, levels_dbm=[-90], format=format, ) def test_worldcover_p833_mapping() -> None: coeffs = p833_coefficients_for_class("tree_cover") assert coeffs.gamma_db_per_m == WORLDCOVER_P833["tree_cover"].gamma_db_per_m def test_antenna_pattern_file(tmp_path: Path) -> None: pattern_file = tmp_path / "pattern.csv" pattern_file.write_text( "# azimuth, elevation, relative_gain\n" "0,0,0\n" "90,0,-3\n" "180,0,-20\n", encoding="utf-8", ) pattern = AntennaPattern(pattern="file", gain_dbi=8, pattern_file=str(pattern_file)) assert gain(pattern, 0, 0) == 8 assert gain(pattern, 90, 0) == 5 @pytest.mark.parametrize("model", ["itm", "p452", "p1812"]) def test_propagation_models_return_positive_loss(model: str) -> None: from app.core.geo import GeoPoint from app.core.propagation import itm_loss, p452_loss, p1812_field tx = GeoPoint(lat=59.935, lon=30.305) rx = GeoPoint(lat=59.945, lon=30.325) profile = [10.0, 12.0, 15.0, 13.0, 11.0] kwargs = { "tx_height_agl": 30.0, "rx_height_agl": 2.0, "freq_mhz": 433.0, "elevation_profile_m": profile, } if model == "itm": loss = itm_loss(tx, rx, **kwargs) elif model == "p452": loss = p452_loss(tx, rx, **kwargs) else: loss = p1812_field(tx, rx, environment="rural", **kwargs) assert loss > 0