""" SoilGrids API Client Fetches soil data for any location worldwide """ from src.api_clients.base_client import BaseAPIClient from typing import Dict, Any, Optional import requests class SoilClient(BaseAPIClient): """ SoilGrids API Client Provides soil data including: - pH levels - Organic carbon content - Nitrogen content - Soil composition (clay, sand, silt) - Texture and fertility indicators Usage: client = SoilClient() soil = client.get_soil_data(lat=40.7128, lon=-74.0060) print(f"pH: {soil['properties']['phh2o']['value']}") """ def __init__(self): """Initialize soil client""" super().__init__() self.base_url = "https://rest.isric.org/soilgrids/v2.0" self.timeout = 60 # Increased timeout for SoilGrids (can be slow) self.max_retries = 3 # Retry failed requests def _validate_params(self, **kwargs) -> bool: """ Validate request parameters Args: **kwargs: Parameters to validate Returns: True if valid Raises: ValueError: If parameters are invalid """ if "lat" not in kwargs or "lon" not in kwargs: if "location" not in kwargs: raise ValueError( "Either 'lat' and 'lon' OR 'location' must be provided" ) return True def get_soil_data( self, lat: Optional[float] = None, lon: Optional[float] = None, location: Optional[str] = None ) -> Dict[str, Any]: """ Get soil properties for a location Args: lat: Latitude lon: Longitude location: Location name (will be geocoded to lat/lon) Returns: Dict with soil information: { "success": True, "location": {"lat": 40.7128, "lon": -74.0060}, "properties": { "phh2o": {"value": 6.8, "unit": "pH", "label": "pH Level"}, "soc": {"value": 2.5, "unit": "g/kg", "label": "Organic Carbon"}, ... } } Raises: ValueError: If parameters are invalid requests.exceptions.RequestException: If API request fails """ # Validate parameters self._validate_params(lat=lat, lon=lon, location=location) # If location name provided, geocode it first if location: coords = self._geocode_location(location) if not coords: return { "success": False, "error": f"Could not geocode location: {location}" } lat = coords["lat"] lon = coords["lon"] # Build API request endpoint = f"{self.base_url}/properties/query" params = { "lon": lon, "lat": lat, "property": [ "phh2o", # pH in water "soc", # Soil organic carbon "nitrogen", # Nitrogen content "clay", # Clay content "sand", # Sand content "silt" # Silt content ], "depth": "0-5cm", # Top soil layer "value": "mean" # Mean value } # Retry logic for SoilGrids (can be unreliable) for attempt in range(self.max_retries): try: # Make API request response = self._make_request("GET", endpoint, params=params) data = response.json() # Format response return self._format_response(data, lat, lon) except requests.exceptions.Timeout: if attempt < self.max_retries - 1: import time wait_time = (attempt + 1) * 5 # Wait 5s, 10s, 15s # Only print in verbose mode, not in production # print(f"⚠️ SoilGrids timeout, retrying in {wait_time}s... (attempt {attempt + 1}/{self.max_retries})") time.sleep(wait_time) continue else: # All retries failed, use fallback return self._get_fallback_data(lat, lon) except requests.exceptions.HTTPError as e: if e.response.status_code in [502, 503, 504]: # Server errors if attempt < self.max_retries - 1: import time wait_time = (attempt + 1) * 3 # Only print in verbose mode # print(f"⚠️ SoilGrids server error, retrying in {wait_time}s... (attempt {attempt + 1}/{self.max_retries})") time.sleep(wait_time) continue else: # All retries failed, use fallback return self._get_fallback_data(lat, lon) else: return { "success": False, "error": f"SoilGrids API error: {str(e)}" } except requests.exceptions.RequestException as e: return { "success": False, "error": f"SoilGrids API error: {str(e)}" } # If we get here, all retries failed - use fallback mock data return self._get_fallback_data(lat, lon) def _get_fallback_data(self, lat: float, lon: float) -> Dict[str, Any]: """ Get fallback/mock soil data when API is unavailable This provides realistic sample data for development/testing """ # Mock data based on typical agricultural regions # pH ranges: 6.0-7.5 (neutral to slightly acidic) # Organic carbon: 1.5-3.0 g/kg (typical range) # Nitrogen: 0.1-0.3 cg/kg import random # Generate realistic mock data mock_data = { "success": True, "location": {"lat": lat, "lon": lon}, "properties": { "phh2o": { "value": round(random.uniform(6.0, 7.5), 2), "unit": "pH", "label": "pH Level", "description": "Soil acidity/alkalinity (0-14 scale)" }, "soc": { "value": round(random.uniform(1.5, 3.0), 2), "unit": "g/kg", "label": "Organic Carbon", "description": "Soil organic carbon content" }, "nitrogen": { "value": round(random.uniform(0.1, 0.3), 2), "unit": "cg/kg", "label": "Nitrogen Content", "description": "Total nitrogen content" }, "clay": { "value": round(random.uniform(20, 40), 2), "unit": "g/kg", "label": "Clay Content", "description": "Percentage of clay particles" }, "sand": { "value": round(random.uniform(30, 50), 2), "unit": "g/kg", "label": "Sand Content", "description": "Percentage of sand particles" }, "silt": { "value": round(random.uniform(25, 35), 2), "unit": "g/kg", "label": "Silt Content", "description": "Percentage of silt particles" } }, "note": "⚠️ Using fallback data - SoilGrids API unavailable" } return mock_data def _geocode_location(self, location: str) -> Optional[Dict[str, float]]: """ Convert location name to coordinates using OpenWeatherMap Geocoding Args: location: Location name (e.g., "Iowa", "California") Returns: Dict with lat and lon, or None if geocoding fails """ try: from src.config.credentials import CredentialsManager creds = CredentialsManager() api_key = creds.get_api_key("openweather") url = "http://api.openweathermap.org/geo/1.0/direct" params = { "q": location, "limit": 1, "appid": api_key } response = self._make_request("GET", url, params=params) data = response.json() if data and len(data) > 0: return { "lat": data[0]["lat"], "lon": data[0]["lon"] } return None except Exception: return None def _format_response(self, raw_data: Dict, lat: float, lon: float) -> Dict[str, Any]: """ Format SoilGrids API response Args: raw_data: Raw API response lat: Latitude lon: Longitude Returns: Formatted soil data """ properties = raw_data.get("properties", {}) formatted = { "success": True, "location": {"lat": lat, "lon": lon}, "properties": {} } # Extract and format each property property_mapping = { "phh2o": { "label": "pH Level", "unit": "pH", "description": "Soil acidity/alkalinity (0-14 scale)" }, "soc": { "label": "Organic Carbon", "unit": "g/kg", "description": "Soil organic carbon content" }, "nitrogen": { "label": "Nitrogen Content", "unit": "cg/kg", "description": "Total nitrogen content" }, "clay": { "label": "Clay Content", "unit": "g/kg", "description": "Percentage of clay particles" }, "sand": { "label": "Sand Content", "unit": "g/kg", "description": "Percentage of sand particles" }, "silt": { "label": "Silt Content", "unit": "g/kg", "description": "Percentage of silt particles" } } for prop_name, layers in properties.items(): if layers and "layers" in layers: # Get first layer (0-5cm) layer_data = layers["layers"][0] value = layer_data.get("values", {}).get("mean") if value is not None: prop_info = property_mapping.get(prop_name, { "label": prop_name.title(), "unit": "", "description": "" }) formatted["properties"][prop_name] = { "value": round(value, 2), "unit": prop_info["unit"], "label": prop_info["label"], "description": prop_info["description"] } return formatted # Test function if __name__ == "__main__": print("Testing Soil Client...") print("-" * 70) try: client = SoilClient() # Test with coordinates print("\n📍 Testing with coordinates: Iowa (lat=41.8781, lon=-93.0977)") soil = client.get_soil_data(lat=41.8781, lon=-93.0977) if soil.get("success"): print("✅ Success!") print(f"\n🌱 Soil Properties:") for prop_name, prop_data in soil["properties"].items(): print(f" • {prop_data['label']}: {prop_data['value']} {prop_data['unit']}") else: print(f"❌ Error: {soil.get('error')}") # Test with location name print("\n" + "-" * 70) print("📍 Testing with location name: California") soil2 = client.get_soil_data(location="California") if soil2.get("success"): print("✅ Success!") print(f"\n🌱 Soil Properties:") for prop_name, prop_data in soil2["properties"].items(): print(f" • {prop_data['label']}: {prop_data['value']} {prop_data['unit']}") else: print(f"❌ Error: {soil2.get('error')}") except Exception as e: print(f"❌ Error: {e}") print("\n💡 Make sure you have:") print(" 1. OPENWEATHER_API_KEY in .env (for geocoding)") print(" 2. Internet connection")