From c56da4f51c04ca8f16d6eb5337822c58966942ea Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Mon, 15 Dec 2025 09:22:42 +0100 Subject: [PATCH] fix:parzellen-fetching und geolinien --- .../connectors/connectorSwissTopoMapServer.py | 1087 ++++++++++++++--- modules/features/realEstate/mainRealEstate.py | 93 ++ .../interfaces/interfaceDbRealEstateAccess.py | 8 +- modules/routes/routeDataConnections.py | 2 +- modules/routes/routeRealEstate.py | 211 ++-- 5 files changed, 1150 insertions(+), 251 deletions(-) diff --git a/modules/connectors/connectorSwissTopoMapServer.py b/modules/connectors/connectorSwissTopoMapServer.py index 42dd667b..97838507 100644 --- a/modules/connectors/connectorSwissTopoMapServer.py +++ b/modules/connectors/connectorSwissTopoMapServer.py @@ -9,7 +9,8 @@ Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify import logging import asyncio -from typing import Dict, List, Any, Optional, Tuple +import re +from typing import Dict, List, Any, Optional, Tuple, Union import aiohttp from modules.shared.configuration import APP_CONFIG @@ -61,6 +62,158 @@ class SwissTopoMapServerConnector: logger.info("Swiss Topo MapServer Connector initialized") + def _point_in_polygon(self, point_x: float, point_y: float, polygon_rings: List[List[List[float]]]) -> bool: + """ + Check if a point is inside a polygon using ray casting algorithm. + + Args: + point_x: X coordinate of the point + point_y: Y coordinate of the point + polygon_rings: List of rings (each ring is a list of [x, y] coordinates) + First ring is outer boundary, subsequent rings are holes + + Returns: + True if point is inside polygon, False otherwise + """ + if not polygon_rings or not polygon_rings[0]: + return False + + # Use outer ring (first ring) + ring = polygon_rings[0] + if len(ring) < 3: + return False + + # Ray casting algorithm: count intersections of horizontal ray with polygon edges + inside = False + j = len(ring) - 1 + + for i in range(len(ring)): + xi, yi = ring[i][0], ring[i][1] + xj, yj = ring[j][0], ring[j][1] + + # Check if ray crosses edge + if ((yi > point_y) != (yj > point_y)) and (point_x < (xj - xi) * (point_y - yi) / (yj - yi) + xi): + inside = not inside + j = i + + # If point is inside outer ring, check if it's in any hole + if inside and len(polygon_rings) > 1: + for hole_ring in polygon_rings[1:]: + if self._point_in_polygon(point_x, point_y, [hole_ring]): + inside = False # Point is in a hole, so not in polygon + break + + return inside + + def _clean_municipality_name(self, municipality: str) -> str: + """ + Clean municipality name by removing canton suffix. + + Args: + municipality: Municipality name (e.g., "Zürich ZH" or "Lohn (SH)") + + Returns: + Cleaned municipality name (e.g., "Zürich" or "Lohn") + """ + if not municipality: + return municipality + + # Remove parentheses content like "Lohn (SH)" -> "Lohn" + municipality = municipality.split("(")[0].strip() + + # Remove canton code at the end if present (e.g., "Wald ZH" -> "Wald") + parts = municipality.split() + if len(parts) > 1 and len(parts[-1]) == 2 and parts[-1].isupper(): + municipality = " ".join(parts[:-1]) + + return municipality + + def _extract_address_from_building_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Optional[str]]: + """ + Extract address components from building layer attributes. + + Args: + attrs: Attributes dictionary from building layer query + + Returns: + Dictionary with street, house_number, plz, municipality, and full_address + """ + # Extract address components (note: strname is a list!) + street_list = attrs.get("strname", []) + street = street_list[0] if isinstance(street_list, list) and street_list else None + house_number = attrs.get("deinr") + plz = attrs.get("dplz4") + municipality = attrs.get("dplzname") or attrs.get("ggdename") + + # Clean municipality name + if municipality: + municipality = self._clean_municipality_name(municipality) + + # Construct full address + full_address = None + if street and house_number and plz and municipality: + full_address = f"{street} {house_number}, {plz} {municipality}" + elif street and house_number: + full_address = f"{street} {house_number}" + if plz: + full_address += f", {plz}" + if municipality: + full_address += f" {municipality}" + + return { + 'street': street, + 'house_number': str(house_number) if house_number else None, + 'plz': str(plz) if plz else None, + 'municipality': municipality, + 'full_address': full_address + } + + async def _query_building_layer( + self, + x: float, + y: float, + tolerance: int = 1, # Default to 1 pixel (minimum) for maximum precision + buffer: int = 25 # Reduced buffer for more precise queries + ) -> Optional[Dict[str, Any]]: + """ + Query the building/address layer at given coordinates. + + Args: + x: X coordinate (LV95) + y: Y coordinate (LV95) + tolerance: Tolerance in pixels for identify operation + buffer: Buffer in meters for map extent + + Returns: + First building result dictionary, or None if not found + """ + try: + building_extent = f"{x - buffer},{y - buffer},{x + buffer},{y + buffer}" + + building_params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "sr": "2056", + "layers": "all:ch.bfs.gebaeude_wohnungs_register", + "tolerance": tolerance, + "mapExtent": building_extent, + "imageDisplay": "800,600,96", + "returnGeometry": "false", + "f": "json" + } + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.get(self.MAPSERVER_IDENTIFY_URL, params=building_params) as response: + if response.status == 200: + building_data = await response.json() + building_results = building_data.get("results", []) + if building_results: + return building_results[0] + return None + except Exception as e: + logger.debug(f"Could not query building layer: {e}") + return None + def _validate_coordinates(self, x: float, y: float) -> None: """ Validate coordinates are within Switzerland bounds (LV95). @@ -83,6 +236,68 @@ class SwissTopoMapServerConnector: f"({self.SWITZERLAND_BOUNDS['min_y']} - {self.SWITZERLAND_BOUNDS['max_y']})" ) + def _extract_coordinates_from_search_result(self, result: Dict[str, Any]) -> Optional[Tuple[float, float]]: + """ + Extract LV95 coordinates from SearchServer API result. + Handles different response formats and coordinate swapping. + + Args: + result: Search result from SearchServer API + + Returns: + Tuple of (x, y) in LV95 coordinates, or None if not found + """ + attrs = result.get("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: + bbox_str = attrs['geom_st_box2d'] + 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: + 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.warning(f"Extracted X coordinate {x} is out of LV95 bounds") + return None + + if not (self.SWITZERLAND_BOUNDS["min_y"] <= y <= self.SWITZERLAND_BOUNDS["max_y"]): + logger.warning(f"Extracted Y coordinate {y} is out of LV95 bounds") + return None + + return (x, y) + async def _make_request( self, url: str, @@ -146,26 +361,50 @@ class SwissTopoMapServerConnector: raise Exception(f"API request failed after {self.max_retries + 1} attempts") - async def geocode_address(self, address: str) -> Optional[Tuple[float, float]]: + async def geocode_address(self, address: str, return_full_result: bool = False) -> Optional[Union[Tuple[float, float], Dict[str, Any]]]: """ Geocode an address to LV95 coordinates. Args: address: Address string (e.g. "Bundesplatz 3, 3003 Bern") + return_full_result: If True, returns dict with coords and full result. If False, returns just coords tuple. Returns: - Tuple of (x, y) in LV95 coordinates, or None if not found + If return_full_result=False: Tuple of (x, y) in LV95 coordinates, or None if not found + If return_full_result=True: Dict with 'coords' (x, y tuple) and 'result' (full geocoding result), or None if not found """ try: - # Use sr=2056 to request LV95 coordinates + # Normalize address: ensure postal code is properly formatted + # Swiss postal codes are 4 digits, often separated from street + normalized_address = address.strip() + + # Try to detect and format postal code pattern: "Street Number POSTALCODE" + # Pattern: ends with 4 digits (postal code) + postal_code_pattern = r'\b(\d{4})\b' + postal_code_match = re.search(postal_code_pattern, normalized_address) + + if postal_code_match: + postal_code = postal_code_match.group(1) + # Format as "Street Number, POSTALCODE" for better geocoding + # This helps the API understand the structure better + parts = normalized_address.split() + if len(parts) >= 2 and parts[-1] == postal_code: + # Already ends with postal code, format with comma + street_part = " ".join(parts[:-1]) + normalized_address = f"{street_part}, {postal_code}" + logger.debug(f"Normalized address format: '{normalized_address}'") + + # Request multiple results to find the best match + # Use "address" origin to prioritize address results over parcels params = { - "searchText": address, + "searchText": normalized_address, "type": "locations", + "origins": "address", # Prioritize address results "sr": "2056", # Request coordinates in LV95 (EPSG:2056) - "limit": 1 + "limit": 10 # Get more results to find best match } - logger.info(f"Geocoding address: {address}") + logger.info(f"Geocoding address: {address} (normalized: {normalized_address})") response = await self._make_request(self.GEOCODING_URL, params) results = response.get("results", []) @@ -173,158 +412,121 @@ class SwissTopoMapServerConnector: logger.warning(f"No geocoding results for address: {address}") return None - # Extract coordinates from first result - result = results[0] + # Normalize input address for comparison + input_normalized = address.lower().strip() + input_parts = [p.strip() for p in input_normalized.split() if p.strip()] + + # Try to find the best matching result + best_match = None + best_score = 0 + + for result in results: + # Extract address label from result (remove HTML tags) + result_label_raw = result.get("label", "") + # Remove HTML tags like ... + result_label = re.sub(r'<[^>]+>', '', result_label_raw).lower().strip() + attrs = result.get("attrs", {}) + + # Extract address components + street = attrs.get("strname", "") + if isinstance(street, list): + street = street[0] if street else "" + house_number = str(attrs.get("deinr", "")).strip() + plz = str(attrs.get("dplz4", "")).strip() + municipality = attrs.get("dplzname", "") or attrs.get("ggdename", "") + + # Build address components for comparison + result_parts = [] + if street: + result_parts.append(street.lower()) + if house_number: + result_parts.append(house_number) + if plz: + result_parts.append(plz) + if municipality: + # Remove canton suffix + municipality_clean = municipality.split("(")[0].strip().split() + if len(municipality_clean) > 1 and len(municipality_clean[-1]) == 2: + municipality_clean = municipality_clean[:-1] + result_parts.append(" ".join(municipality_clean).lower()) + + # Also use the label for matching (it often has the full address) + if result_label: + label_parts = [p.strip() for p in result_label.split() if len(p.strip()) > 2] + result_parts.extend(label_parts) + + # Calculate match score + score = 0 + matched_parts = 0 + + # Check if input parts match result parts + for input_part in input_parts: + for result_part in result_parts: + if input_part in result_part or result_part in input_part: + score += 1 + matched_parts += 1 + break + + # Prefer results with higher match score + if matched_parts > 0: + score = matched_parts / max(len(input_parts), len(result_parts)) + + logger.debug(f"Geocoding result match: label='{result_label}', score={score:.2f}, matched={matched_parts}/{len(input_parts)}") + + if score > best_score: + best_score = score + best_match = result + + # Use best match or fall back to first result + result = best_match if best_match else results[0] + + if best_match: + logger.info(f"Selected best match with score {best_score:.2f}: {result.get('label', 'Unknown')}") + else: + logger.info(f"Using first result: {result.get('label', 'Unknown')}") + 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: + coords = self._extract_coordinates_from_search_result(result) + if coords: + x, y = coords + logger.info(f"Geocoded address '{address}' to LV95 coordinates: ({x}, {y})") + + if return_full_result: + return { + 'coords': coords, + 'result': result + } + else: + return coords + else: 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 + tolerance: int = 5 # Reduced default tolerance for more precise parcel matching ) -> Optional[Dict[str, Any]]: """ - Search for parcel by address or coordinates. + Search for parcel by address or coordinates using MapServer identify. 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 + Parcel information dictionary with geocoded address info, or None if not found """ try: + original_location = location + geocoded_address_info = None + geocoded_coords = None + # Try to parse as coordinates first parts = location.split(",") if len(parts) == 2: @@ -332,18 +534,271 @@ class SwissTopoMapServerConnector: 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) + # Validate coordinates + self._validate_coordinates(x, y) except ValueError: - pass # Not coordinates, try geocoding + # Not coordinates, try geocoding - get full result + geocode_result = await self.geocode_address(location, return_full_result=True) + if geocode_result is None: + logger.warning(f"Could not geocode location: {location}") + return None + x, y = geocode_result['coords'] + geocoded_address_info = geocode_result['result'] + else: + # Treat as address and geocode - get full result including address info + geocode_result = await self.geocode_address(location, return_full_result=True) + if geocode_result is None: + logger.warning(f"Could not geocode location: {location}") + return None + + x, y = geocode_result['coords'] + geocoded_address_info = geocode_result['result'] + + # Log the geocoded address info + if geocoded_address_info: + label_raw = geocoded_address_info.get('label', '') + label_clean = re.sub(r'<[^>]+>', '', label_raw).strip() + logger.info(f"Geocoded address info: {label_clean}") - # Treat as address and geocode - coords = await self.geocode_address(location) - if coords is None: - logger.warning(f"Could not geocode location: {location}") + # First, query the building layer with minimal tolerance to get the exact building + # This is critical for precise map clicks - we want the exact building, not nearby ones + is_coordinate_search = len(parts) == 2 and not geocoded_address_info + # Use 1 pixel tolerance (minimum) for coordinate clicks to ensure we get results + building_tolerance = 1 if is_coordinate_search else 10 + building_result = await self._query_building_layer(x, y, tolerance=building_tolerance, buffer=25) + + building_parcel_id = None + if building_result: + building_attrs = building_result.get("attributes", {}) + building_parcel_id = building_attrs.get("lparz") + logger.debug(f"Found building with parcel ID: {building_parcel_id}") + + # Use MapServer identify to get parcel info + # For coordinate searches, use minimal tolerance (1 pixel) for spatial containment + # Note: tolerance=0 might be too strict, use 1 pixel minimum + parcel_tolerance = 1 if is_coordinate_search else tolerance + extent_buffer = 1000 # 1km buffer + map_extent = f"{x - extent_buffer},{y - extent_buffer},{x + extent_buffer},{y + extent_buffer}" + + params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "sr": "2056", # LV95 + "layers": self.LAYER_AMTLICHE_VERMESSUNG, + "tolerance": parcel_tolerance, + "mapExtent": map_extent, + "imageDisplay": "800,600,96", + "returnGeometry": "true", + "f": "json" + } + + logger.info(f"Querying parcel at coordinates: ({x}, {y}) with tolerance: {parcel_tolerance}") + response = await self._make_request(self.MAPSERVER_IDENTIFY_URL, params) + + results = response.get("results", []) + if not results: + logger.warning(f"No parcel found at coordinates: ({x}, {y})") return None - x, y = coords - return await self.get_parcel_info(x, y, tolerance) + # Strategy 1: If we have a building parcel ID, find the matching parcel + parcel_data = None + if building_parcel_id: + building_parcel_id_normalized = str(building_parcel_id).strip().upper() + + for result in results: + result_label = str(result.get('attributes', {}).get('label', '')).strip().upper() + result_number = str(result.get('attributes', {}).get('number', '')).strip().upper() + + if (result_label == building_parcel_id_normalized or + result_number == building_parcel_id_normalized or + result_label.endswith(building_parcel_id_normalized) or + building_parcel_id_normalized in result_label): + parcel_data = result + logger.info(f"Found matching parcel by building parcel ID: {result_label} (building had: {building_parcel_id})") + break + + # Strategy 2: For coordinate searches, always check spatial containment (point-in-polygon) + # This ensures we get the parcel that actually contains the point, not just the nearest one + # We MUST use spatial containment for coordinate searches - no legacy fallback + if is_coordinate_search: + logger.debug("Checking spatial containment for coordinate search") + + containing_parcels = [] + + for result in results: + geometry = result.get('geometry', {}) + if not geometry: + continue + + # Extract polygon rings from geometry + rings = geometry.get('rings', []) + if rings: + try: + # Check if point is inside this parcel's polygon + if self._point_in_polygon(x, y, rings): + containing_parcels.append(result) + parcel_label = result.get('attributes', {}).get('label', 'Unknown') + logger.debug(f"Point is inside parcel: {parcel_label}") + except Exception as e: + logger.debug(f"Error checking point-in-polygon for parcel {result.get('attributes', {}).get('label', 'Unknown')}: {e}") + continue + + if not containing_parcels: + # No parcels contain the point - this should not happen for valid coordinates + logger.warning(f"No parcels contain point ({x}, {y}) - this may indicate a data issue") + return None + + # If we already have a parcel from building match, verify it's in containing parcels + if parcel_data: + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + parcel_in_containing = any( + p.get('attributes', {}).get('label') == parcel_label + for p in containing_parcels + ) + if not parcel_in_containing: + logger.warning(f"Building-matched parcel {parcel_label} does not contain point, using spatial containment instead") + parcel_data = None + + # If no parcel yet or building match was invalid, use spatial containment + if not parcel_data: + if building_parcel_id: + # Prefer parcel matching building parcel ID among containing parcels + building_parcel_id_normalized = str(building_parcel_id).strip().upper() + for parcel in containing_parcels: + result_label = str(parcel.get('attributes', {}).get('label', '')).strip().upper() + if (result_label == building_parcel_id_normalized or + result_label.endswith(building_parcel_id_normalized) or + building_parcel_id_normalized in result_label): + parcel_data = parcel + logger.info(f"Found matching containing parcel by building parcel ID: {result_label}") + break + + # If no building match, use first containing parcel + if not parcel_data: + parcel_data = containing_parcels[0] + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + logger.info(f"Found parcel by spatial containment: {parcel_label} (from {len(containing_parcels)} containing parcels)") + + # Fallback for address searches only (not coordinate searches) + if not parcel_data and not is_coordinate_search: + parcel_data = results[0] + if building_parcel_id: + logger.debug(f"Could not find parcel matching building parcel ID {building_parcel_id}, using first result: {parcel_data.get('attributes', {}).get('label', 'Unknown')}") + + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + logger.info(f"Found parcel: {parcel_label}") + + # Store the query coordinates (used for address lookup, not parcel centroid) + parcel_data['query_coordinates'] = {'x': x, 'y': y} + + # Attach geocoded address info to parcel_data for use by the route + if geocoded_address_info: + # Extract address components from geocoded result + attrs = geocoded_address_info.get('attrs', {}) + + # Get label and clean HTML tags first + label_raw = geocoded_address_info.get('label', '') + label_clean = re.sub(r'<[^>]+>', '', label_raw).strip() + + # Extract address components - try multiple sources + street_list = attrs.get("strname", []) + street = street_list[0] if isinstance(street_list, list) and street_list else None + + # House number can be in attrs.deinr or top-level num + house_number = attrs.get("deinr") or geocoded_address_info.get("num") + if house_number: + house_number = str(house_number).strip() + + # Postal code + plz = attrs.get("dplz4") + if plz: + plz = str(plz).strip() + + # Municipality + municipality = attrs.get("dplzname") or attrs.get("ggdename") + + # If street not found in attrs, try to parse from label + if not street and label_clean: + # Parse "Street Number" from label (e.g., "Ueberlandstrasse 11 8050 Zürich") + label_parts = label_clean.split() + if len(label_parts) >= 2: + # Try to find street name (everything before the number) + for i in range(len(label_parts) - 1, 0, -1): + if label_parts[i].isdigit(): + street = " ".join(label_parts[:i]) + break + + # If postal code not found, try to extract from label + if not plz and label_clean: + # Look for 4-digit postal code in label + postal_match = re.search(r'\b(\d{4})\b', label_clean) + if postal_match: + plz = postal_match.group(1) + + # If municipality not found, try to extract from label + if not municipality and label_clean: + # Municipality is usually after postal code + # Format: "Street Number POSTALCODE Municipality" + label_parts = label_clean.split() + if len(label_parts) >= 2: + # Find postal code position and take what comes after + for i, part in enumerate(label_parts): + if len(part) == 4 and part.isdigit(): + if i + 1 < len(label_parts): + municipality = " ".join(label_parts[i+1:]) + break + + # Clean municipality name + if municipality: + municipality = self._clean_municipality_name(municipality) + + # Construct full address - prefer label_clean, but build from components if needed + if label_clean: + full_address = label_clean + elif street and house_number and plz and municipality: + full_address = f"{street} {house_number}, {plz} {municipality}" + elif street and house_number: + full_address = f"{street} {house_number}" + if plz: + full_address += f", {plz}" + if municipality: + full_address += f" {municipality}" + else: + full_address = label_clean or original_location + + parcel_data['geocoded_address'] = { + 'label': label_clean, + 'street': street, + 'house_number': house_number, + 'plz': plz, + 'municipality': municipality, + 'full_address': full_address + } + + logger.debug(f"Attached geocoded address to parcel: {full_address}") + logger.debug(f"Address components - street: {street}, house_number: {house_number}, plz: {plz}, municipality: {municipality}") + + # Log warning if geocoded address doesn't match input (for debugging) + if geocoded_address_info and original_location and any(c.isalpha() for c in original_location): + label_raw = geocoded_address_info.get('label', '') + geocoded_label = re.sub(r'<[^>]+>', '', label_raw).lower().strip() + original_lower = original_location.lower() + + # Check if key parts match + original_parts = set(p.strip() for p in original_lower.split() if len(p.strip()) > 2) + geocoded_parts = set(p.strip() for p in geocoded_label.split() if len(p.strip()) > 2) + + # Check if street name and number are similar + match_score = len(original_parts.intersection(geocoded_parts)) / max(len(original_parts), 1) + + if match_score < 0.5: + logger.warning( + f"Geocoded address may not match input! " + f"Input: '{original_location}' -> Geocoded: '{geocoded_label}' " + f"(match score: {match_score:.2f})" + ) + + return parcel_data except Exception as e: logger.error(f"Error searching parcel for location '{location}': {e}") @@ -380,19 +835,351 @@ class SwissTopoMapServerConnector: return extracted - def _convert_geometry_to_geopolylinie(self, geometry: Dict[str, Any]) -> Optional[Dict[str, Any]]: + async def get_parcel_polygon( + self, + gemeinde: str, + parzellen_nr: str, + sr: int = 2056 + ) -> Optional[Dict[str, Any]]: """ - Convert ESRI geometry to GeoPolylinie format. + Holt die vollständige Polygon-Geometrie einer Parzelle nach Gemeinde und Parzellennummer. Args: - geometry: ESRI geometry from MapServer response + gemeinde: Name der Gemeinde (z.B. "Bern") + parzellen_nr: Parzellennummer (z.B. "1234") + sr: Koordinatensystem (2056=LV95, 4326=WGS84) + + Returns: + GeoJSON-Feature mit Polygon-Koordinaten oder None wenn nicht gefunden + """ + try: + # Schritt 1: Parzelle suchen + search_params = { + "searchText": f"{gemeinde} {parzellen_nr}", + "type": "locations", + "origins": "parcel", + "sr": str(sr) + } + + logger.info(f"Searching for parcel: {gemeinde} {parzellen_nr}") + response = await self._make_request(self.GEOCODING_URL, search_params) + + search_results = response.get("results", []) + if not search_results: + logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden") + return None + + # Extract coordinates using shared helper method + search_result = search_results[0] + coords = self._extract_coordinates_from_search_result(search_result) + + if coords is None: + logger.warning(f"Could not extract coordinates from search result") + return None + + x, y = coords + logger.info(f"Parzelle gefunden: {parcel.get('label', 'Unknown')}, Zentrum: E={x}, N={y}") + + # Schritt 2: Polygon-Geometrie abrufen + identify_params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "layers": self.LAYER_AMTLICHE_VERMESSUNG, + "tolerance": "0", + "returnGeometry": "true", + "geometryFormat": "geojson", + "sr": str(sr), + "imageDisplay": "0,0,0", + "mapExtent": "0,0,0,0" + } + + logger.info(f"Fetching polygon geometry for parcel at ({x}, {y})") + identify_response = await self._make_request(self.MAPSERVER_IDENTIFY_URL, identify_params) + + identify_results = identify_response.get("results", []) + if not identify_results: + logger.warning("Keine Geometrie gefunden") + return None + + # Return the GeoJSON feature + feature = identify_results[0] + logger.info(f"Successfully retrieved polygon geometry with {len(feature.get('geometry', {}).get('coordinates', [[]])[0])} points") + + return feature + + except Exception as e: + logger.error(f"Error getting parcel polygon for {gemeinde} {parzellen_nr}: {e}", exc_info=True) + return None + + def extract_boundary_points(self, feature: Dict[str, Any]) -> List[List[float]]: + """ + Extrahiert alle Grenzpunkte aus dem GeoJSON-Feature. + + Args: + feature: GeoJSON Feature mit Polygon-Geometrie + + Returns: + Liste von Koordinatenpaaren [[x, y], ...] in LV95 + """ + try: + geometry = feature.get("geometry", {}) + + if geometry.get("type") == "Polygon": + # Äusserer Ring = erste Liste von Koordinaten + return geometry["coordinates"][0] + elif geometry.get("type") == "MultiPolygon": + # Alle Ringe aller Polygone + all_points = [] + for polygon in geometry["coordinates"]: + all_points.extend(polygon[0]) + return all_points + + return [] + + except Exception as e: + logger.error(f"Error extracting boundary points: {e}") + return [] + + async def find_neighboring_parcels( + self, + parcel_data: Dict[str, Any], + selected_parcel_id: str, + sample_distance: float = 20.0, # Balanced distance for good coverage + max_sample_points: int = 30, # Increased to ensure all vertices + some intermediate points + max_neighbors: int = 15, # Increased to find more neighbors + max_concurrent: int = 50 # Process up to 50 queries concurrently (maximum parallelization) + ) -> List[Dict[str, Any]]: + """ + Find all parcels that touch the boundary of the selected parcel. + + This method samples points along the parcel boundary and queries parcels + at each point to find all neighboring parcels that actually touch the boundary. + Optimized for speed with intelligent sampling and early stopping. + + Args: + parcel_data: The selected parcel's data dictionary (must contain geometry) + selected_parcel_id: The ID of the selected parcel (to exclude from results) + sample_distance: Distance in meters between sample points along the boundary (default: 15m) + max_sample_points: Maximum number of sample points to query (default: 30) + max_neighbors: Stop early if we find this many unique neighbors (default: 15) + + Returns: + List of neighboring parcel dictionaries with id, egrid, and number + """ + try: + geometry = parcel_data.get("geometry", {}) + if not geometry: + logger.warning("No geometry found in parcel_data for finding neighbors") + return [] + + # Extract boundary points from geometry + boundary_points = [] + + # Handle ESRI format (rings) + if "rings" in geometry and geometry["rings"]: + ring = geometry["rings"][0] # Outer ring + for coord in ring: + if len(coord) >= 2: + boundary_points.append((coord[0], coord[1])) + + # Handle GeoJSON format + elif geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + for coord in ring: + if len(coord) >= 2: + boundary_points.append((coord[0], coord[1])) + + if not boundary_points: + logger.warning("No boundary points found in parcel geometry") + return [] + + # Sample points along the boundary, offset outward + # Increased offset to ensure we're outside the selected parcel + offset_distance = 8.0 # meters - increased from 2.0 to ensure we're outside + sampled_points = [] + + # Calculate centroid for determining outward direction + centroid_x = sum(p[0] for p in boundary_points) / len(boundary_points) + centroid_y = sum(p[1] for p in boundary_points) / len(boundary_points) + + # First pass: Always include ALL vertices (critical for finding all neighbors) + # Don't limit vertices - they're essential for coverage + edge_info = [] # Store edge information for intermediate sampling + for i in range(len(boundary_points)): + p1 = boundary_points[i] + p2 = boundary_points[(i + 1) % len(boundary_points)] + + # Calculate edge vector + edge_dx = p2[0] - p1[0] + edge_dy = p2[1] - p1[1] + edge_length = (edge_dx**2 + edge_dy**2)**0.5 + + if edge_length > 0: + # Calculate perpendicular vector (pointing outward) + perp_dx = -edge_dy / edge_length + perp_dy = edge_dx / edge_length + + # Check if this points outward (away from centroid) + to_point_dx = p1[0] - centroid_x + to_point_dy = p1[1] - centroid_y + if (perp_dx * to_point_dx + perp_dy * to_point_dy) < 0: + perp_dx = -perp_dx + perp_dy = -perp_dy + + # Offset vertex point outward + offset_p1 = (p1[0] + perp_dx * offset_distance, p1[1] + perp_dy * offset_distance) + sampled_points.append(offset_p1) + + # Store edge info for intermediate sampling + edge_info.append({ + 'p1': p1, + 'edge_length': edge_length, + 'perp_dx': perp_dx, + 'perp_dy': perp_dy, + 'edge_dx': edge_dx, + 'edge_dy': edge_dy + }) + + # Second pass: Add intermediate points for edges longer than sample_distance + # This ensures we don't miss neighbors that touch in the middle of long edges + for edge in edge_info: + if len(sampled_points) >= max_sample_points: + break + + # Add intermediate points for edges longer than sample_distance + if edge['edge_length'] > sample_distance: + # Calculate how many intermediate points we need + num_samples = int(edge['edge_length'] / sample_distance) + # Limit to reasonable number per edge (max 3) to avoid too many points + num_samples = min(num_samples, 3) + + for j in range(1, num_samples + 1): + if len(sampled_points) >= max_sample_points: + break + t = j / (num_samples + 1) + # Interpolate along edge + interp_x = edge['p1'][0] + t * edge['edge_dx'] + interp_y = edge['p1'][1] + t * edge['edge_dy'] + # Offset outward + offset_x = interp_x + edge['perp_dx'] * offset_distance + offset_y = interp_y + edge['perp_dy'] * offset_distance + sampled_points.append((offset_x, offset_y)) + + # Don't limit vertices, but limit total if we have too many intermediate points + # This ensures we always have all vertices for complete coverage + if len(sampled_points) > max_sample_points: + # Keep all vertices, limit intermediate points + vertex_count = len(boundary_points) + if vertex_count <= max_sample_points: + # Keep vertices + some intermediate points + sampled_points = sampled_points[:max_sample_points] + else: + # Too many vertices - keep all vertices anyway (they're critical) + sampled_points = sampled_points[:len(sampled_points)] + + # Query parcels in parallel batches for much faster performance + neighboring_parcels = {} + tolerance = 2 # Small tolerance for boundary queries + + async def query_point(point: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Query a single point and return parcel data if found.""" + try: + x, y = point + location_str = f"{x},{y}" + adj_data = await self.search_parcel(location_str, tolerance=tolerance) + + if adj_data: + adj_attrs = adj_data.get("attributes", {}) + adj_id = adj_attrs.get("label") or adj_attrs.get("number") + + # Exclude the selected parcel itself + if adj_id and adj_id != selected_parcel_id: + # Extract geometry information + adj_geometry = adj_data.get("geometry", {}) + extracted_attrs = self.extract_parcel_attributes(adj_data) + + return { + "id": adj_id, + "egrid": adj_attrs.get("egris_egrid"), + "number": adj_attrs.get("number"), + "perimeter": extracted_attrs.get("perimeter"), + "geometry": adj_geometry + } + except Exception: + # Silently skip errors for speed + pass + return None + + # Process ALL queries concurrently for maximum speed + # Use semaphore to limit concurrent connections to avoid overwhelming the API + semaphore = asyncio.Semaphore(max_concurrent) + + async def query_point_with_semaphore(point: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Query a point with semaphore-controlled concurrency.""" + async with semaphore: + return await query_point(point) + + # Launch all queries concurrently (semaphore will limit actual concurrency) + results = await asyncio.gather(*[query_point_with_semaphore(point) for point in sampled_points], return_exceptions=True) + + # Process all results (minimal logging for speed) + for result in results: + if isinstance(result, Exception): + continue + + if result and result["id"] not in neighboring_parcels: + neighboring_parcels[result["id"]] = result + + # Early stop if we've found enough neighbors + if len(neighboring_parcels) >= max_neighbors: + break + + result_list = list(neighboring_parcels.values()) + logger.info(f"Found {len(result_list)} neighboring parcels for parcel {selected_parcel_id} (queried {len(sampled_points)} points)") + + return result_list + + except Exception as e: + logger.error(f"Error finding neighboring parcels: {e}", exc_info=True) + return [] + + def _convert_geometry_to_geopolylinie(self, geometry: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Convert ESRI geometry or GeoJSON to GeoPolylinie format. + + Args: + geometry: ESRI geometry (rings) or GeoJSON geometry from MapServer response Returns: GeoPolylinie-compatible dictionary or None """ try: - # Handle polygon geometry (rings) - if "rings" in geometry and geometry["rings"]: + # Handle GeoJSON format (from get_parcel_polygon) + if geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95", + "x": coord[0], # GeoJSON: [x, y] = [easting, northing] + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + return { + "closed": True, + "punkte": punkte + } + + # Handle ESRI format (rings) - existing code + elif "rings" in geometry and geometry["rings"]: ring = geometry["rings"][0] # Take first ring (outer boundary) punkte = [] diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 4c7c994d..5da41413 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -12,13 +12,77 @@ from modules.datamodels.datamodelRealEstate import ( Projekt, Parzelle, StatusProzess, + GeoPolylinie, ) from modules.services import getInterface as getServices from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface +from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector logger = logging.getLogger(__name__) +# ===== Swisstopo Integration ===== + +async def fetch_parcel_polygon_from_swisstopo( + gemeinde: str, + parzellen_nr: str, + sr: int = 2056 +) -> Optional[Dict[str, Any]]: + """ + Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API. + + Args: + gemeinde: Name der Gemeinde (z.B. "Bern") + parzellen_nr: Parzellennummer (z.B. "1234") + sr: Koordinatensystem (2056=LV95, 4326=WGS84) + + Returns: + Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden + Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]} + """ + try: + connector = SwissTopoMapServerConnector() + + # Get GeoJSON feature from Swisstopo + feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr) + + if not feature: + logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo") + return None + + # Convert GeoJSON to GeoPolylinie format + geometry = feature.get("geometry", {}) + if geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95" if sr == 2056 else "WGS84", + "x": coord[0], # GeoJSON: [x, y] = [easting, northing] + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}") + + return { + "closed": True, + "punkte": punkte + } + + logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}") + return None + + except Exception as e: + logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True) + return None + + # ===== Direkte Query-Ausführung (stateless) ===== async def executeDirectQuery( @@ -600,6 +664,35 @@ async def executeIntentBasedOperation( # Handle complex objects if "perimeter" in parameters and parameters["perimeter"]: parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"]) + elif "kontextGemeinde" in parameters and parameters.get("kontextGemeinde"): + # Try to fetch polygon from Swisstopo if gemeinde and parzellen_nr are available + gemeinde = parameters.get("kontextGemeinde") + parzellen_nr = parameters.get("label") or parameters.get("parzellen_nr") or parameters.get("parzellennummer") + + if gemeinde and parzellen_nr: + logger.info(f"Attempting to fetch polygon from Swisstopo for {gemeinde} {parzellen_nr}") + try: + # Try to resolve gemeinde name if it's an ID + gemeinde_name = gemeinde + if len(gemeinde) == 36: # UUID format + # Try to get gemeinde name from interface (realEstateInterface already initialized above) + gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) + if gemeinde_obj: + gemeinde_name = gemeinde_obj.label + + polygon_data = await fetch_parcel_polygon_from_swisstopo( + gemeinde=gemeinde_name, + parzellen_nr=str(parzellen_nr), + sr=2056 + ) + + if polygon_data: + parzelle_data["perimeter"] = GeoPolylinie(**polygon_data) + logger.info(f"Successfully fetched and set perimeter from Swisstopo") + else: + logger.warning(f"Could not fetch polygon from Swisstopo for {gemeinde_name} {parzellen_nr}") + except Exception as e: + logger.warning(f"Error fetching polygon from Swisstopo (continuing without): {e}") if "baulinie" in parameters and parameters["baulinie"]: parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"]) diff --git a/modules/interfaces/interfaceDbRealEstateAccess.py b/modules/interfaces/interfaceDbRealEstateAccess.py index 9a25293f..937aa2ef 100644 --- a/modules/interfaces/interfaceDbRealEstateAccess.py +++ b/modules/interfaces/interfaceDbRealEstateAccess.py @@ -5,7 +5,7 @@ Handles user access management and permission checks. import logging from typing import Dict, Any, List, Optional -from modules.datamodels.datamodelUam import User, UserPrivilege +from modules.datamodels.datamodelUam import User logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ class RealEstateAccess: self.currentUser = currentUser self.mandateId = currentUser.mandateId self.userId = currentUser.id - self.privilege = currentUser.privilege + self.roleLabels = currentUser.roleLabels or [] if not self.mandateId or not self.userId: raise ValueError("Invalid user context: mandateId and userId are required") @@ -42,10 +42,10 @@ class RealEstateAccess: filtered_records = [] # System admins see all records - if self.privilege == UserPrivilege.SYSADMIN: + if "sysadmin" in self.roleLabels: filtered_records = recordset # Admins see records in their mandate - elif self.privilege == UserPrivilege.ADMIN: + elif "admin" in self.roleLabels: filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId] # Regular users see only their records else: diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 28ebc12a..5fb6e9a4 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -17,7 +17,7 @@ import math from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelSecurity import Token -from modules.security.auth import getCurrentUser, limiter +from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbAppObjects import getInterface from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 50964402..73168af9 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -10,7 +10,7 @@ from typing import Optional, Dict, Any, List, Union from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status # Import auth modules -from modules.security.auth import limiter, getCurrentUser +from modules.auth import limiter, getCurrentUser # Import models from modules.datamodels.datamodelUam import User @@ -653,70 +653,45 @@ async def search_parcel( full_address = None plz = None - # Try to get address by querying the address layer at the parcel centroid - if centroid: - try: - import aiohttp + # First, try to use geocoded address info if available (more accurate than centroid query) + geocoded_address = parcel_data.get('geocoded_address') + if geocoded_address: + full_address = geocoded_address.get('full_address') + plz = geocoded_address.get('plz') + municipality_name = geocoded_address.get('municipality') + logger.debug(f"Using geocoded address: {full_address}") + + # If geocoded address not available, try to get address by querying the address layer + # Use query coordinates (where user clicked/geocoded) instead of parcel centroid + # This ensures we get the address at the exact location, not at the parcel center + query_coords = parcel_data.get('query_coordinates') + address_query_coords = query_coords if query_coords else centroid + + if not full_address and address_query_coords: + query_x = address_query_coords['x'] + query_y = address_query_coords['y'] + logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})") + + # Check if this was a coordinate search (not geocoded address) + is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) + + # Use connector's helper method to query building layer + # Use tolerance=1 (minimum) for coordinate searches to get exact building + building_tolerance = 1 if is_coordinate_search else 10 + building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25) + + if building_result: + addr_attrs = building_result.get("attributes", {}) + logger.debug(f"Address layer attributes: {addr_attrs}") - # Use MapServer identify on address layer to get actual address - identify_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" + # Extract address using connector's helper method + address_info = connector._extract_address_from_building_attrs(addr_attrs) + full_address = address_info.get('full_address') + plz = address_info.get('plz') + municipality_name = address_info.get('municipality') - # Calculate extent around centroid - buffer = 100 # 100m buffer - map_extent = f"{centroid['x'] - buffer},{centroid['y'] - buffer},{centroid['x'] + buffer},{centroid['y'] + buffer}" - - params = { - "geometry": f"{centroid['x']},{centroid['y']}", - "geometryType": "esriGeometryPoint", - "sr": "2056", - "layers": "all:ch.bfs.gebaeude_wohnungs_register", # Building/address layer - "tolerance": 50, - "mapExtent": map_extent, - "imageDisplay": "800,600,96", - "returnGeometry": "false", - "f": "json" - } - - logger.debug(f"Querying address layer at centroid: ({centroid['x']}, {centroid['y']})") - - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: - async with session.get(identify_url, params=params) as response: - if response.status == 200: - address_data = await response.json() - address_results = address_data.get("results", []) - - if address_results: - # Get first address result - addr_attrs = address_results[0].get("attributes", {}) - logger.debug(f"Address layer attributes: {addr_attrs}") - - # Extract address components (note: strname is a list!) - street_list = addr_attrs.get("strname", []) - street = street_list[0] if isinstance(street_list, list) and street_list else None - house_number = addr_attrs.get("deinr") - plz = addr_attrs.get("dplz4") - municipality_name = addr_attrs.get("dplzname") or addr_attrs.get("ggdename") - - # Clean municipality name (remove canton suffix like "Wald ZH" -> "Wald") - if municipality_name and " " in municipality_name: - # Format is often "City ZH" or "City (ZH)" - municipality_name = municipality_name.split("(")[0].strip() - # Remove canton code at the end if present - parts = municipality_name.split() - if len(parts) > 1 and len(parts[-1]) == 2 and parts[-1].isupper(): - municipality_name = " ".join(parts[:-1]) - - # Construct full address - if street and house_number and plz and municipality_name: - full_address = f"{street} {house_number}, {plz} {municipality_name}" - logger.debug(f"Constructed address: {full_address}") - else: - logger.debug("No address results from building layer") - else: - logger.debug(f"Address identify returned status {response.status}") - - except Exception as e: - logger.debug(f"Could not query address layer: {e}") + if full_address: + logger.debug(f"Constructed address: {full_address}") # If address not found via building layer, try to construct from available data if not full_address: @@ -813,46 +788,90 @@ async def search_parcel( } # Fetch adjacent parcels if requested - if include_adjacent and centroid: + if include_adjacent and parcel_data and parcel_data.get("geometry"): try: - # Search in a radius around the parcel centroid - # Note: This is a simplified approach - may need refinement - adjacent_parcels = [] + # Use the connector's method to find neighboring parcels by sampling along the boundary + # This ensures we find all parcels that actually touch the selected parcel + selected_parcel_id = parcel_info["id"] + adjacent_parcels_raw = await connector.find_neighboring_parcels( + parcel_data=parcel_data, + selected_parcel_id=selected_parcel_id, + sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed) + max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered + max_neighbors=15, # Find up to 15 neighbors + max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization) + ) - # Search in 4 directions from centroid (N, S, E, W) - search_distance = 50 # meters - search_coords = [ - (centroid["x"], centroid["y"] + search_distance), # North - (centroid["x"], centroid["y"] - search_distance), # South - (centroid["x"] + search_distance, centroid["y"]), # East - (centroid["x"] - search_distance, centroid["y"]), # West - ] + # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging) + def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]: + """Convert a single adjacent parcel to include GeoJSON geometry.""" + adj_parcel_with_geo = { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number"), + "perimeter": adj_parcel.get("perimeter") + } + + # Convert geometry to GeoJSON format if available + adj_geometry = adj_parcel.get("geometry") + adj_perimeter = adj_parcel.get("perimeter") + + if adj_geometry: + # Handle ESRI format (rings) + if "rings" in adj_geometry and adj_geometry["rings"]: + ring = adj_geometry["rings"][0] # Outer ring + coordinates = [[[p[0], p[1]] for p in ring]] + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": coordinates + }, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + # Handle GeoJSON format + elif adj_geometry.get("type") == "Polygon": + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": adj_geometry, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + + # If no geometry_geojson was created but we have perimeter, create it from perimeter + if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"): + punkte = adj_perimeter["punkte"] + coordinates = [[[p["x"], p["y"]] for p in punkte]] + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": coordinates + }, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + + return adj_parcel_with_geo - for coord in search_coords: - try: - adj_data = await connector.get_parcel_info(coord[0], coord[1], tolerance=5) - if adj_data: - adj_attrs = adj_data.get("attributes", {}) - adj_id = adj_attrs.get("label") or adj_attrs.get("number") - - # Don't include the same parcel - if adj_id != parcel_info["id"]: - # Check if already in list - if not any(p["id"] == adj_id for p in adjacent_parcels): - adjacent_parcels.append({ - "id": adj_id, - "egrid": adj_attrs.get("egris_egrid"), - "number": adj_attrs.get("number") - }) - except Exception as e: - logger.debug(f"No adjacent parcel found at {coord}: {e}") - continue + # Convert all parcels in parallel (using list comprehension for speed) + adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw] response_data["adjacent_parcels"] = adjacent_parcels - logger.info(f"Found {len(adjacent_parcels)} adjacent parcels") + logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}") except Exception as e: - logger.warning(f"Error fetching adjacent parcels: {e}") + logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) response_data["adjacent_parcels"] = [] return response_data