""" 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