agAdvisor / src /api_clients /soil_client.py
tirtho149's picture
Deploy AgAdvisor
b30f068 verified
Raw
History Blame Contribute Delete
13.2 kB
"""
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")