gateway/modules/connectors/connectorSwissTopoMapServer.py

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