Files
geointel/backend/app/services/geojson_service.py
T
Codex 8c694fa9ce
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
fix: make vector ingestion canonical and atomic
2026-07-14 15:28:14 +02:00

140 lines
4.5 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import json
from pyproj import Transformer, CRS
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.ops import transform as _transform_geometry
from shapely.validation import make_valid
def parse_geojson_payload(raw_text: str | dict[str, Any]) -> dict[str, Any]:
if isinstance(raw_text, dict):
payload = raw_text
else:
try:
payload = json.loads(raw_text)
except Exception as exc:
raise ValueError("Uploaded dataset is not valid JSON") from exc
if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
raise ValueError("Upload must be a GeoJSON FeatureCollection")
features = payload.get("features") or []
if not isinstance(features, list):
raise ValueError("FeatureCollection features is invalid")
geometry_types: set[str] = set()
geometries = []
invalid_features = 0
z_dimension_features = 0
polygon_area_m2: float | None = None
crs_assumed = None
for feature in features:
if not isinstance(feature, dict):
continue
geometry = feature.get("geometry")
if not geometry:
continue
try:
geom = shape(geometry)
except Exception as exc:
raise ValueError("Invalid feature geometry") from exc
if not geom.is_valid:
geom = make_valid(geom)
if not geom.is_valid:
invalid_features += 1
raise ValueError("Invalid geometry remains after repair")
if geom.has_z:
z_dimension_features += 1
geometry_types.add(str(geom.geom_type))
geometries.append(geom)
if geometries:
unioned = unary_union(geometries)
bounds = unioned.bounds
bounds_json = {
"min_x": float(bounds[0]),
"min_y": float(bounds[1]),
"max_x": float(bounds[2]),
"max_y": float(bounds[3]),
}
else:
bounds_json = None
crs = None
crs_assumed = False
raw_crs = payload.get("crs")
if isinstance(raw_crs, dict):
raw_name = raw_crs.get("properties", {}).get("name")
if isinstance(raw_name, str):
crs = raw_name
elif isinstance(raw_crs, str):
crs = raw_crs
if not crs:
crs = "EPSG:4326"
crs_assumed = True
polygon_area_m2 = _approximate_polygon_area_m2(geometries, crs)
return {
"feature_count": len(features),
"geometry_types": sorted(geometry_types),
"bounds_json": bounds_json,
"approximate_area_m2": polygon_area_m2,
"invalid_features": invalid_features,
"z_dimension_feature_count": z_dimension_features,
"canonical_storage_dimension": "2D",
"crs": crs,
"crs_assumed": crs_assumed,
"extracted_at": datetime.now(timezone.utc).isoformat(),
"feature_geometry_count": len(geometries),
}
def load_dataset_text(file_path: str) -> str:
return Path(file_path).read_text(encoding="utf-8")
def _approximate_polygon_area_m2(geometries: list[BaseGeometry], crs: str | None) -> float | None:
if not geometries:
return 0.0
try:
polygons = [geometry for geometry in geometries if geometry.geom_type.lower() in {"polygon", "multipolygon"}]
if not polygons:
return None
target_crs = CRS.from_epsg(31370)
source_crs = _crs_to_epsg(crs)
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
projected = [_transform_polygon_for_area(geometry, transformer) for geometry in polygons]
area = sum(item.area for item in projected)
if area < 0:
area = 0.0
return float(area)
except Exception:
return None
def _crs_to_epsg(value: str | None) -> str:
if not value:
return "EPSG:4326"
normalized = value.upper().strip().replace(" ", "")
if normalized.startswith("EPSG:"):
return normalized
if normalized.replace("-", "").isdigit():
return f"EPSG:{normalized}"
return "EPSG:4326"
def _transform_polygon_for_area(geometry: BaseGeometry, transformer: Transformer):
if geometry.is_empty:
return geometry
if geometry.geom_type.lower() in {"polygon", "multipolygon"}:
return _transform_geometry(transformer.transform, geometry)
return geometry