419 lines
17 KiB
Python
419 lines
17 KiB
Python
"""
|
|
Swiss Topo MapServer Connector (Simplified)
|
|
|
|
This connector handles interactions with the Swiss Federal Office of Topography
|
|
MapServer identify endpoint for parcel data retrieval.
|
|
|
|
Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
|
"""
|
|
|
|
import logging
|
|
import asyncio
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
import aiohttp
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SwissTopoMapServerConnector:
|
|
"""
|
|
Simplified connector for Swiss Topo MapServer identify endpoint.
|
|
|
|
Provides methods for:
|
|
- Searching parcels by coordinates (LV95)
|
|
- Geocoding addresses to coordinates
|
|
- Extracting parcel information
|
|
"""
|
|
|
|
# API endpoints
|
|
MAPSERVER_IDENTIFY_URL = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
|
GEOCODING_URL = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
|
|
|
# Swiss official survey layer
|
|
LAYER_AMTLICHE_VERMESSUNG = "all:ch.swisstopo-vd.amtliche-vermessung"
|
|
|
|
# Switzerland bounds in LV95 (EPSG:2056)
|
|
SWITZERLAND_BOUNDS = {
|
|
"min_x": 2480000,
|
|
"max_x": 2840000,
|
|
"min_y": 1070000,
|
|
"max_y": 1300000
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
timeout: int = 30,
|
|
max_retries: int = 3,
|
|
retry_delay: float = 1.0
|
|
):
|
|
"""
|
|
Initialize MapServer connector.
|
|
|
|
Args:
|
|
timeout: Request timeout in seconds
|
|
max_retries: Maximum number of retry attempts
|
|
retry_delay: Initial retry delay in seconds (exponential backoff)
|
|
"""
|
|
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
|
self.max_retries = max_retries
|
|
self.retry_delay = retry_delay
|
|
|
|
logger.info("Swiss Topo MapServer Connector initialized")
|
|
|
|
def _validate_coordinates(self, x: float, y: float) -> None:
|
|
"""
|
|
Validate coordinates are within Switzerland bounds (LV95).
|
|
|
|
Args:
|
|
x: East coordinate (LV95)
|
|
y: North coordinate (LV95)
|
|
|
|
Raises:
|
|
ValueError: If coordinates are out of bounds
|
|
"""
|
|
if not (self.SWITZERLAND_BOUNDS["min_x"] <= x <= self.SWITZERLAND_BOUNDS["max_x"]):
|
|
raise ValueError(
|
|
f"X coordinate {x} out of bounds "
|
|
f"({self.SWITZERLAND_BOUNDS['min_x']} - {self.SWITZERLAND_BOUNDS['max_x']})"
|
|
)
|
|
if not (self.SWITZERLAND_BOUNDS["min_y"] <= y <= self.SWITZERLAND_BOUNDS["max_y"]):
|
|
raise ValueError(
|
|
f"Y coordinate {y} out of bounds "
|
|
f"({self.SWITZERLAND_BOUNDS['min_y']} - {self.SWITZERLAND_BOUNDS['max_y']})"
|
|
)
|
|
|
|
async def _make_request(
|
|
self,
|
|
url: str,
|
|
params: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Make HTTP GET request with retry logic and exponential backoff.
|
|
|
|
Args:
|
|
url: API endpoint URL
|
|
params: Query parameters
|
|
|
|
Returns:
|
|
Response JSON data
|
|
|
|
Raises:
|
|
aiohttp.ClientError: If request fails after all retries
|
|
"""
|
|
for attempt in range(self.max_retries + 1):
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
|
async with session.get(url, params=params) as response:
|
|
# Don't retry on 4xx errors (client errors)
|
|
if 400 <= response.status < 500:
|
|
error_text = await response.text()
|
|
logger.error(f"API client error: {response.status} - {error_text}")
|
|
response.raise_for_status()
|
|
|
|
# Retry on 5xx errors (server errors)
|
|
if response.status >= 500:
|
|
if attempt < self.max_retries:
|
|
delay = self.retry_delay * (2 ** attempt)
|
|
logger.warning(
|
|
f"API server error {response.status}, "
|
|
f"retrying in {delay}s (attempt {attempt + 1}/{self.max_retries + 1})"
|
|
)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
else:
|
|
error_text = await response.text()
|
|
logger.error(f"API request failed after {self.max_retries + 1} attempts: {response.status} - {error_text}")
|
|
response.raise_for_status()
|
|
|
|
return await response.json()
|
|
|
|
except aiohttp.ClientError as e:
|
|
if attempt < self.max_retries:
|
|
delay = self.retry_delay * (2 ** attempt)
|
|
logger.warning(
|
|
f"API network error, retrying in {delay}s "
|
|
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
|
)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
else:
|
|
logger.error(f"API request failed after {self.max_retries + 1} attempts: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in API request: {e}")
|
|
raise
|
|
|
|
raise Exception(f"API request failed after {self.max_retries + 1} attempts")
|
|
|
|
async def geocode_address(self, address: str) -> Optional[Tuple[float, float]]:
|
|
"""
|
|
Geocode an address to LV95 coordinates.
|
|
|
|
Args:
|
|
address: Address string (e.g. "Bundesplatz 3, 3003 Bern")
|
|
|
|
Returns:
|
|
Tuple of (x, y) in LV95 coordinates, or None if not found
|
|
"""
|
|
try:
|
|
# Use sr=2056 to request LV95 coordinates
|
|
params = {
|
|
"searchText": address,
|
|
"type": "locations",
|
|
"sr": "2056", # Request coordinates in LV95 (EPSG:2056)
|
|
"limit": 1
|
|
}
|
|
|
|
logger.info(f"Geocoding address: {address}")
|
|
response = await self._make_request(self.GEOCODING_URL, params)
|
|
|
|
results = response.get("results", [])
|
|
if not results:
|
|
logger.warning(f"No geocoding results for address: {address}")
|
|
return None
|
|
|
|
# Extract coordinates from first result
|
|
result = results[0]
|
|
logger.debug(f"Geocoding result: {result}")
|
|
|
|
attrs = result.get("attrs", {})
|
|
|
|
# The SearchServer API returns coordinates in different ways:
|
|
# 1. With sr=2056: 'y' (northing/Y) and 'x' (easting/X) in LV95
|
|
# 2. Without sr: 'lat' and 'lon' in WGS84
|
|
# 3. Sometimes in top-level result, not in attrs
|
|
|
|
# Try multiple extraction patterns
|
|
x = None
|
|
y = None
|
|
|
|
# Pattern 1: From 'geom_st_box2d' (most reliable - has correct X,Y order)
|
|
if 'geom_st_box2d' in attrs:
|
|
# Format: "BOX(xmin ymin,xmax ymax)" where x=easting, y=northing
|
|
bbox_str = attrs['geom_st_box2d']
|
|
# Extract center point from bbox
|
|
import re
|
|
match = re.search(r'BOX\(([0-9.]+) ([0-9.]+),([0-9.]+) ([0-9.]+)\)', bbox_str)
|
|
if match:
|
|
xmin, ymin, xmax, ymax = map(float, match.groups())
|
|
x = (xmin + xmax) / 2 # Easting
|
|
y = (ymin + ymax) / 2 # Northing
|
|
logger.debug(f"Extracted coordinates from geom_st_box2d: X={x}, Y={y}")
|
|
# Pattern 2: From attrs with 'x' and 'y' keys (WARNING: these are SWAPPED!)
|
|
# The Swiss Geo Admin API returns x=northing, y=easting (opposite of standard)
|
|
elif 'x' in attrs and 'y' in attrs:
|
|
# SWAP: their 'x' is our Y, their 'y' is our X
|
|
x = attrs.get('y') # Their Y is easting (our X)
|
|
y = attrs.get('x') # Their X is northing (our Y)
|
|
logger.debug(f"Extracted swapped coordinates from attrs: X={x} (from 'y'), Y={y} (from 'x')")
|
|
# Pattern 3: From attrs with 'easting' and 'northing' keys
|
|
elif 'easting' in attrs and 'northing' in attrs:
|
|
x = attrs.get('easting')
|
|
y = attrs.get('northing')
|
|
logger.debug(f"Extracted coordinates from easting/northing: X={x}, Y={y}")
|
|
# Pattern 4: From top-level result (also likely swapped)
|
|
elif 'x' in result and 'y' in result:
|
|
x = result.get('y') # Swap
|
|
y = result.get('x') # Swap
|
|
logger.debug(f"Extracted swapped coordinates from result: X={x}, Y={y}")
|
|
|
|
if x is None or y is None:
|
|
logger.warning(f"No coordinates found in geocoding result. Result: {result}")
|
|
return None
|
|
|
|
# Convert to float
|
|
x = float(x)
|
|
y = float(y)
|
|
|
|
# Validate that coordinates are in LV95 range
|
|
if not (self.SWITZERLAND_BOUNDS["min_x"] <= x <= self.SWITZERLAND_BOUNDS["max_x"]):
|
|
logger.error(
|
|
f"Geocoded X coordinate {x} is out of LV95 bounds. "
|
|
f"The API might have returned coordinates in a different system. "
|
|
f"Full result: {result}"
|
|
)
|
|
return None
|
|
|
|
if not (self.SWITZERLAND_BOUNDS["min_y"] <= y <= self.SWITZERLAND_BOUNDS["max_y"]):
|
|
logger.error(
|
|
f"Geocoded Y coordinate {y} is out of LV95 bounds. "
|
|
f"The API might have returned coordinates in a different system. "
|
|
f"Full result: {result}"
|
|
)
|
|
return None
|
|
|
|
logger.info(f"Geocoded address '{address}' to LV95 coordinates: ({x}, {y})")
|
|
return (x, y)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error geocoding address '{address}': {e}", exc_info=True)
|
|
return None
|
|
|
|
async def get_parcel_info(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
tolerance: int = 10
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get parcel information at given coordinates using MapServer identify.
|
|
|
|
Args:
|
|
x: East coordinate (LV95/EPSG:2056)
|
|
y: North coordinate (LV95/EPSG:2056)
|
|
tolerance: Tolerance in pixels for identify operation
|
|
|
|
Returns:
|
|
Parcel information dictionary or None if not found
|
|
"""
|
|
try:
|
|
# Validate coordinates
|
|
self._validate_coordinates(x, y)
|
|
|
|
# Calculate map extent (small area around point)
|
|
extent_buffer = 1000 # 1km buffer
|
|
map_extent = f"{x - extent_buffer},{y - extent_buffer},{x + extent_buffer},{y + extent_buffer}"
|
|
|
|
# Build identify request parameters
|
|
params = {
|
|
"geometry": f"{x},{y}",
|
|
"geometryType": "esriGeometryPoint",
|
|
"sr": "2056", # LV95
|
|
"layers": self.LAYER_AMTLICHE_VERMESSUNG,
|
|
"tolerance": tolerance,
|
|
"mapExtent": map_extent,
|
|
"imageDisplay": "800,600,96",
|
|
"returnGeometry": "true", # Get geometry for perimeter
|
|
"f": "json"
|
|
}
|
|
|
|
logger.info(f"Querying parcel info at coordinates: ({x}, {y})")
|
|
logger.debug(f"MapServer identify params: {params}")
|
|
|
|
response = await self._make_request(self.MAPSERVER_IDENTIFY_URL, params)
|
|
|
|
# Extract results
|
|
results = response.get("results", [])
|
|
if not results:
|
|
logger.warning(f"No parcel found at coordinates: ({x}, {y})")
|
|
return None
|
|
|
|
# Return first result (should be the parcel)
|
|
parcel_data = results[0]
|
|
logger.info(f"Found parcel: {parcel_data.get('attributes', {}).get('label', 'Unknown')}")
|
|
|
|
return parcel_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting parcel info at ({x}, {y}): {e}")
|
|
raise
|
|
|
|
async def search_parcel(
|
|
self,
|
|
location: str,
|
|
tolerance: int = 10
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search for parcel by address or coordinates.
|
|
|
|
Args:
|
|
location: Either coordinates as "x,y" (LV95) or address string
|
|
tolerance: Tolerance in pixels for identify operation
|
|
|
|
Returns:
|
|
Parcel information dictionary or None if not found
|
|
"""
|
|
try:
|
|
# Try to parse as coordinates first
|
|
parts = location.split(",")
|
|
if len(parts) == 2:
|
|
try:
|
|
x = float(parts[0].strip())
|
|
y = float(parts[1].strip())
|
|
logger.info(f"Parsed location as coordinates: ({x}, {y})")
|
|
return await self.get_parcel_info(x, y, tolerance)
|
|
except ValueError:
|
|
pass # Not coordinates, try geocoding
|
|
|
|
# Treat as address and geocode
|
|
coords = await self.geocode_address(location)
|
|
if coords is None:
|
|
logger.warning(f"Could not geocode location: {location}")
|
|
return None
|
|
|
|
x, y = coords
|
|
return await self.get_parcel_info(x, y, tolerance)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching parcel for location '{location}': {e}")
|
|
raise
|
|
|
|
def extract_parcel_attributes(self, parcel_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Extract and normalize parcel attributes from MapServer response.
|
|
|
|
Args:
|
|
parcel_data: Raw parcel data from MapServer identify
|
|
|
|
Returns:
|
|
Dictionary with normalized parcel attributes compatible with Parzelle model
|
|
"""
|
|
attributes = parcel_data.get("attributes", {})
|
|
geometry = parcel_data.get("geometry")
|
|
|
|
# Extract common parcel attributes
|
|
# Note: Attribute names depend on the actual data structure from the API
|
|
# These are common fields, adjust based on actual API response
|
|
extracted = {
|
|
"label": attributes.get("label") or attributes.get("parzellennummer") or attributes.get("nummer") or "Unknown",
|
|
"parzellenAliasTags": [],
|
|
"strasseNr": attributes.get("adresse") or attributes.get("strasse"),
|
|
"plz": attributes.get("plz"),
|
|
"eigentuemerschaft": attributes.get("eigentuemer") or attributes.get("eigentuemerschaft"),
|
|
"bauzone": attributes.get("bauzone") or attributes.get("zonierung"),
|
|
"perimeter": self._convert_geometry_to_geopolylinie(geometry) if geometry else None,
|
|
"kontextGemeinde": attributes.get("gemeinde") or attributes.get("gemeindename"),
|
|
# Additional metadata
|
|
"raw_attributes": attributes # Keep raw data for reference
|
|
}
|
|
|
|
return extracted
|
|
|
|
def _convert_geometry_to_geopolylinie(self, geometry: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Convert ESRI geometry to GeoPolylinie format.
|
|
|
|
Args:
|
|
geometry: ESRI geometry from MapServer response
|
|
|
|
Returns:
|
|
GeoPolylinie-compatible dictionary or None
|
|
"""
|
|
try:
|
|
# Handle polygon geometry (rings)
|
|
if "rings" in geometry and geometry["rings"]:
|
|
ring = geometry["rings"][0] # Take first ring (outer boundary)
|
|
|
|
punkte = []
|
|
for coord in ring:
|
|
if len(coord) >= 2:
|
|
punkt = {
|
|
"koordinatensystem": "LV95",
|
|
"x": coord[0],
|
|
"y": coord[1],
|
|
"z": coord[2] if len(coord) > 2 else None
|
|
}
|
|
punkte.append(punkt)
|
|
|
|
return {
|
|
"closed": True,
|
|
"punkte": punkte
|
|
}
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error converting geometry to GeoPolylinie: {e}")
|
|
return None
|
|
|