203 lines
6.4 KiB
Python
203 lines
6.4 KiB
Python
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
|