Files
RadioPropagationApi/api/app/services/buildings.py
T
2026-06-23 11:33:40 +03:00

169 lines
4.6 KiB
Python

from __future__ import annotations
import json
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.geo import PathPoint
from app.models.buildings import BuildingsQueryRequest, BuildingsQueryResponse
def _feature_from_value(value: Any) -> dict[str, Any]:
if isinstance(value, str):
return json.loads(value)
return dict(value)
def _geometry_from_geojson(geojson: dict[str, Any]) -> dict[str, Any]:
if geojson.get("type") == "Feature":
geometry = geojson.get("geometry")
else:
geometry = geojson
if not isinstance(geometry, dict) or geometry.get("type") not in {
"LineString",
"MultiLineString",
"Polygon",
"MultiPolygon",
}:
raise ValueError("path must be a GeoJSON geometry or Feature")
return geometry
def query_buildings(
request: BuildingsQueryRequest,
db: Session,
limit: int = 5000,
) -> BuildingsQueryResponse:
try:
if request.bbox is not None:
rows = _query_by_bbox(db, request.bbox, limit)
else:
rows = _query_by_path(db, request.path or {}, request.buffer_m, limit)
except SQLAlchemyError as exc:
raise NotImplementedError(
"PostGIS buildings table is not ready. Run scripts/load_buildings.sh first."
) from exc
return BuildingsQueryResponse(features=[_feature_from_value(row["feature"]) for row in rows])
def building_heights_along(points: list[PathPoint], db: Session) -> list[float]:
if not points:
return []
points_json = json.dumps(
[{"i": index, "lon": point.lon, "lat": point.lat} for index, point in enumerate(points)]
)
statement = text(
"""
WITH points AS (
SELECT *
FROM jsonb_to_recordset(CAST(:points_json AS jsonb))
AS p(i integer, lon double precision, lat double precision)
)
SELECT p.i, COALESCE(MAX(b.height_m), 0) AS height_m
FROM points p
LEFT JOIN buildings b
ON ST_Intersects(b.geom, ST_SetSRID(ST_MakePoint(p.lon, p.lat), 4326))
GROUP BY p.i
ORDER BY p.i
"""
)
rows = db.execute(statement, {"points_json": points_json}).mappings().all()
heights = [0.0] * len(points)
for row in rows:
heights[int(row["i"])] = float(row["height_m"] or 0.0)
return heights
def _query_by_bbox(
db: Session,
bbox: tuple[float, float, float, float],
limit: int,
) -> list[dict[str, Any]]:
minlon, minlat, maxlon, maxlat = bbox
statement = text(
"""
SELECT jsonb_build_object(
'type', 'Feature',
'id', osm_id,
'geometry', ST_AsGeoJSON(geom)::jsonb,
'properties', jsonb_build_object(
'height_m', height_m,
'levels', levels,
'building_type', building_type,
'source', source
)
) AS feature
FROM buildings
WHERE geom && ST_MakeEnvelope(:minlon, :minlat, :maxlon, :maxlat, 4326)
AND ST_Intersects(
geom,
ST_MakeEnvelope(:minlon, :minlat, :maxlon, :maxlat, 4326)
)
LIMIT :limit
"""
)
return list(
db.execute(
statement,
{
"minlon": minlon,
"minlat": minlat,
"maxlon": maxlon,
"maxlat": maxlat,
"limit": limit,
},
)
.mappings()
.all()
)
def _query_by_path(
db: Session,
path_geojson: dict[str, Any],
buffer_m: float,
limit: int,
) -> list[dict[str, Any]]:
geometry_json = json.dumps(_geometry_from_geojson(path_geojson))
statement = text(
"""
WITH query_geom AS (
SELECT ST_Transform(
ST_Buffer(
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geometry_json), 4326), 3857),
:buffer_m
),
4326
) AS geom
)
SELECT jsonb_build_object(
'type', 'Feature',
'id', b.osm_id,
'geometry', ST_AsGeoJSON(b.geom)::jsonb,
'properties', jsonb_build_object(
'height_m', b.height_m,
'levels', b.levels,
'building_type', b.building_type,
'source', b.source
)
) AS feature
FROM buildings b, query_geom q
WHERE ST_Intersects(b.geom, q.geom)
LIMIT :limit
"""
)
return list(
db.execute(
statement,
{"geometry_json": geometry_json, "buffer_m": buffer_m, "limit": limit},
)
.mappings()
.all()
)