"""Google Solar API data acquisition. Handles geocoding, GeoTIFF download (RGB, building mask, DSM), and parsing with CRS normalization to EPSG:4326. """ import io import numpy as np import requests import rasterio from rasterio.crs import CRS from PIL import Image def geocode_address(address: str, api_key: str) -> tuple[float, float, str]: """Convert address to lat/lng using Google Geocoding API.""" url = "https://maps.googleapis.com/maps/api/geocode/json" params = {"address": address, "key": api_key} response = requests.get(url, params=params) data = response.json() if data["status"] != "OK": raise ValueError(f"Geocoding failed: {data['status']}") location = data["results"][0]["geometry"]["location"] formatted_address = data["results"][0]["formatted_address"] return location["lat"], location["lng"], formatted_address def fetch_geotiff( lat: float, lng: float, api_key: str, radius_meters: int = 50 ) -> tuple[bytes, bytes | None, bytes | None, dict]: """Fetch RGB GeoTIFF, building mask, and DSM from Google Solar API. Downloads at 0.1 m/pixel resolution. Tries HIGH quality first, falls back to MEDIUM. Returns: (rgb_bytes, mask_bytes, dsm_bytes, layers_info) """ layers_url = "https://solar.googleapis.com/v1/dataLayers:get" params = { "location.latitude": lat, "location.longitude": lng, "radiusMeters": radius_meters, "view": "FULL_LAYERS", "requiredQuality": "HIGH", "pixelSizeMeters": 0.1, "key": api_key, } response = requests.get(layers_url, params=params) if response.status_code != 200: params["requiredQuality"] = "MEDIUM" response = requests.get(layers_url, params=params) if response.status_code != 200: raise ValueError( f"Data Layers API error: {response.status_code} - {response.text}" ) layers = response.json() # RGB imagery rgb_url = layers.get("rgbUrl") if not rgb_url: raise ValueError("No RGB imagery available for this location") rgb_response = requests.get(f"{rgb_url}&key={api_key}") if rgb_response.status_code != 200: raise ValueError(f"Failed to download RGB GeoTIFF: {rgb_response.status_code}") # Building mask mask_bytes = None mask_url = layers.get("maskUrl") if mask_url: mask_response = requests.get(f"{mask_url}&key={api_key}") if mask_response.status_code == 200: mask_bytes = mask_response.content # DSM (Digital Surface Model) dsm_bytes = None dsm_url = layers.get("dsmUrl") if dsm_url: dsm_response = requests.get(f"{dsm_url}&key={api_key}") if dsm_response.status_code == 200: dsm_bytes = dsm_response.content return rgb_response.content, mask_bytes, dsm_bytes, layers def _reproject_bounds(crs, bounds): """Reproject bounds to EPSG:4326 if needed.""" if crs and crs != CRS.from_epsg(4326): from rasterio.warp import transform_bounds bounds = transform_bounds(crs, CRS.from_epsg(4326), *bounds) return bounds def parse_geotiff(geotiff_bytes: bytes) -> tuple[Image.Image, tuple]: """Parse RGB GeoTIFF. Returns (PIL Image, bounds) with WGS84 bounds.""" with rasterio.open(io.BytesIO(geotiff_bytes)) as src: if src.count >= 3: r, g, b = src.read(1), src.read(2), src.read(3) img_array = np.stack([r, g, b], axis=-1) else: band = src.read(1) img_array = np.stack([band] * 3, axis=-1) bounds = _reproject_bounds(src.crs, src.bounds) return Image.fromarray(img_array.astype(np.uint8)), bounds def parse_building_mask(mask_bytes: bytes | None) -> tuple[np.ndarray | None, tuple | None]: """Parse building mask GeoTIFF. Binary: 1=building, 0=not.""" if not mask_bytes: return None, None with rasterio.open(io.BytesIO(mask_bytes)) as src: mask = src.read(1) bounds = _reproject_bounds(src.crs, src.bounds) return mask, bounds def parse_dsm(dsm_bytes: bytes | None) -> tuple[np.ndarray | None, tuple | None]: """Parse DSM GeoTIFF. Returns float height array in meters.""" if not dsm_bytes: return None, None with rasterio.open(io.BytesIO(dsm_bytes)) as src: dsm = src.read(1) bounds = _reproject_bounds(src.crs, src.bounds) return dsm, bounds