# Tutorial

Common workflows for accessing soil data.

## Setup

```{python}
import asyncio
import soildb
import pandas as pd

# Optional: for spatial data
try:
    import geopandas as gpd
    import matplotlib.pyplot as plt
    SPATIAL_AVAILABLE = True
except ImportError:
    SPATIAL_AVAILABLE = False
    print("GeoPandas not available - spatial features will be limited")

# Optional: for soil profile data
try:
    from soilprofilecollection import SoilProfileCollection
    SOIL_PROFILE_COLLECTION_AVAILABLE = True
except ImportError:
    SOIL_PROFILE_COLLECTION_AVAILABLE = False
    print("soilprofilecollection not available - profile analysis will be limited")
```

## 1. Basic Point Queries

Get soil information at a specific location.

```{python}
#| eval: false
async def point_example():
    # Coordinates for Ames, Iowa
    lon, lat = -93.63, 42.03
    
    # Get soil components at this point
    response = await soildb.get_mapunit_by_point(lon, lat)
    df = response.to_pandas()
    
    return df

mu_df = await point_example()
print("Mapunit details:")
print(mu_df[['mukey', 'musym', 'muname']].head())
```

## 2. Survey Area Queries

Get map units for an entire survey area.

```{python}
#| eval: false
async def survey_area_example():
    # Boone County, Iowa
    areasymbol = "IA015"
    
    # Get all map units in this survey area
    response = await soildb.get_mapunit_by_areasymbol(areasymbol)
    df = response.to_pandas()
    
    print(f"Found {len(df)} map units in {areasymbol}")
    print(f"Total area: {df['muacres'].sum():,.0f} acres")
    
    # Show map unit kinds
    mukind_counts = df['mukind'].value_counts()
    print(f"\nMap unit types:")
    for mukind, count in mukind_counts.items():
        print(f"  {mukind}: {count}")
    
    return df

# Run the example
mapunits_df = await survey_area_example()
```

## 3. Spatial Queries

Query soil data using geographic areas.

```{python}
#| eval: false
async def spatial_example():
    # Bounding box around Ames, Iowa
    bbox = {
        "xmin": -93.65, "ymin": 42.02,
        "xmax": -93.60, "ymax": 42.04
    }
    
    # Get map unit polygons in this area
    response = await soildb.spatial_query(
        geometry=bbox,
        table="mupolygon", 
        spatial_relation="intersects",
        return_type="spatial"  # Include geometry
    )
    
    df = response.to_pandas()
    print(f"Found {len(df)} map unit polygons in bounding box")
    
    # Show unique map unit symbols
    print(f"Map unit symbols: {sorted(df['musym'].unique())}")
    
    if SPATIAL_AVAILABLE:
        # Convert to GeoDataFrame for spatial analysis
        gdf = response.to_geodataframe()
        print(f"Total area: {gdf.geometry.to_crs('EPSG:5070').area.sum():.4f} square meters")
        
        # Simple plot
        fig, ax = plt.subplots(figsize=(10, 8))
        gdf.plot(ax=ax, column='musym', legend=True)
        ax.set_title("Map Units in Ames, Iowa Area")
        plt.show()
    
    return df

# Run the example
spatial_df = await spatial_example()
```

## 4. Bulk Data Fetching

Efficiently fetch large amounts of data using pagination.

```{python}
#| eval: false
async def bulk_fetch_example():
    # Get mukeys for multiple survey areas
    areasymbols = ["IA015", "IA109", "IA113"]  # Boone, Polk, Story counties
    
    print("Getting mukeys for survey areas...")
    all_mukeys = await soildb.get_mukey_by_areasymbol(areasymbols)
    print(f"Found {len(all_mukeys)} total map units")
    
    # Fetch map unit data with pagination
    print("Fetching map unit data...")
    response = await soildb.fetch_by_keys(
        keys=all_mukeys,
        table="mapunit",
        columns=["mukey", "muname", "mukind", "muacres"],
        chunk_size=500  # Process in chunks of 500
    )
    
    mapunit_df = response.to_pandas()
    print(f"Retrieved data for {len(mapunit_df)} map units")
    
    # Summary statistics
    print(f"Total area: {mapunit_df['muacres'].sum():,.0f} acres")
    print(f"Average map unit size: {mapunit_df['muacres'].mean():.1f} acres")
    
    # Get components for a sample of map units
    sample_mukeys = all_mukeys[:100]  # First 100 map units
    print(f"\nFetching components for {len(sample_mukeys)} map units...")
    
    comp_response = await soildb.fetch_component_by_mukey(sample_mukeys)
    comp_df = comp_response.to_pandas()
    print(f"Found {len(comp_df)} components")
    
    return mapunit_df, comp_df

# Run the example
mapunit_bulk_df, components_bulk_df = await bulk_fetch_example()
```

## 5. Hierarchical Data Analysis

Work with the soil survey hierarchy: Map Unit, Component, and Component Horizon.

```{python}
#| eval: false
async def hierarchy_example():
    # Start with a survey area
    areasymbol = "IA015"
    
    # 1. Get map units
    mapunit_response = await soildb.get_mapunit_by_areasymbol(areasymbol)
    mapunit_df = mapunit_response.to_pandas()
    
    # Take a sample for detailed analysis
    sample_mukeys = mapunit_df['mukey'].head(10).tolist()
    
    # 2. Get components for these map units
    comp_response = await soildb.fetch_component_by_mukey(sample_mukeys)
    comp_df = comp_response.to_pandas()
    
    # 3. Get horizons for major components
    major_components = comp_df[comp_df['majcompflag'] == 'Yes']
    cokeys = major_components['cokey'].tolist()
    
    hz_response = await soildb.fetch_chorizon_by_cokey(cokeys, columns=["chkey", "cokey", "hzdept_r", "hzdepb_r", "claytotal_r", "sandtotal_r", "silttotal_r", "om_r"])
    
    print("Soil Survey Hierarchy Analysis (DataFrame method)")
    print("="*50)
    print(f"Map Units: {len(mapunit_df)}")
    print(f"Components (sample): {len(comp_df)}")
    print(f"Major Components: {len(major_components)}")
    
    hz_df = hz_response.to_pandas()
    print(f"Horizons: {len(hz_df)}")
    
    # Analyze horizon data with pandas
    if not hz_df.empty:
        print(f"\nHorizon Analysis (pandas):")
        print(f"Average depth: {hz_df['hzdepb_r'].mean():.1f} cm")
        print(f"Deepest horizon: {hz_df['hzdepb_r'].max():.0f} cm")
        
        # Clay content analysis
        clay_data = hz_df['claytotal_r'].dropna()
        if not clay_data.empty:
            print(f"Average clay content (unweighted): {clay_data.mean():.1f}%")

    # 4. Use soilprofilecollection for richer analysis
    if SOIL_PROFILE_COLLECTION_AVAILABLE and not hz_df.empty:
        print("\n\nSoil Survey Hierarchy Analysis (soilprofilecollection method)")
        print("="*60)
        
        # Create a SoilProfileCollection directly from the response
        spc = hz_response.to_soilprofilecollection(site_data=major_components)
        
        print(f"Created SoilProfileCollection with {len(spc)} profiles.")
        
        # Richer analysis using built-in methods
        print("\nHorizon Analysis (SPC):")
        print(f"Deepest profile: {spc.depth.max():.0f} cm")
        
        # Get mean clay content weighted by horizon thickness for the top 100cm
        mean_clay = spc.get_prop_value('claytotal_r', depth=100, method='weighted_mean')
        print(f"Mean clay content to 100cm (weighted): {mean_clay.mean():.1f}%")
    
    return mapunit_df, comp_df, hz_df, spc if SOIL_PROFILE_COLLECTION_AVAILABLE else None

# Run the example
mu_df, comp_df, hz_df, spc = await hierarchy_example()
```

## 6. Custom Queries

Build custom SQL queries for specific needs.

```{python}
#| eval: false
async def custom_query_example():
    # Build a custom query for high clay soils
    query = (soildb.Query()
        .select("m.mukey", "m.muname", "c.compname", "h.hzname", "h.claytotal_r", "h.chkey")
        .from_("mapunit m")
        .inner_join("component c", "m.mukey = c.mukey")
        .inner_join("chorizon h", "c.cokey = h.cokey")
        .inner_join("legend l", "m.lkey = l.lkey")
        .where("l.areasymbol = 'IA015'")
        .where("h.claytotal_r > 40")  # High clay content
        .where("c.majcompflag = 'Yes'")
        .order_by("h.claytotal_r", "DESC")
        .limit(20))
    
    print("Custom Query SQL:")
    print(query.to_sql())
    print()
    
    # Execute the query
    async with soildb.SDAClient() as client:
        response = await client.execute(query)
        df = response.to_pandas()
    
    print(f"Found {len(df)} high-clay horizons")
    if not df.empty:
        print("\nHigh clay content soils:")
        print(df[['muname', 'compname', 'hzname', 'claytotal_r']].head())
    
    return df

# Run the example
high_clay_df = await custom_query_example()
```

## 7. Error Handling

Handle common errors gracefully.

```{python}
#| eval: false
async def error_handling_example():
    try:
        # This will work
        response = await soildb.get_mapunit_by_point(-93.6, 42.0)
        print("Successful query")
        
    except soildb.SDAConnectionError:
        print("SDA service unavailable")
        
    except soildb.SDAQueryError as e:
        print(f"Query error: {e}")
        if hasattr(e, 'query'):
            print(f"  Query: {e.query}")
            
    except soildb.SDAMaintenanceError:
        print("SDA service under maintenance")

# Run the example
await error_handling_example()
```

## 8. Working with Different Data Formats

Export data in various formats for different workflows.

```{python}
#| eval: false
async def data_format_example():
    # Get some sample data
    response = await soildb.get_mapunit_by_areasymbol("IA015")
    
    # pandas DataFrame (default)
    pandas_df = response.to_pandas()
    print(f"pandas DataFrame: {pandas_df.shape}")
    
    # Dictionary format
    dict_data = response.to_dict()
    print(f"Dictionary format: {len(dict_data)} records")
    
    # Try polars if available
    try:
        polars_df = response.to_polars()
        print(f"polars DataFrame: {polars_df.shape}")
    except ImportError:
        print("polars not available")
    
    # For spatial data with GeoPandas
    if SPATIAL_AVAILABLE:
        bbox = {"xmin": -93.65, "ymin": 42.02, "xmax": -93.60, "ymax": 42.04}
        spatial_response = await soildb.spatial_query(
            bbox, "mupolygon", return_type="spatial"
        )
        
        gdf = spatial_response.to_geodataframe()
        print(f"GeoDataFrame: {gdf.shape}")
        print(f"Geometry type: {gdf.geometry.geom_type.iloc[0]}")

# Run the example
await data_format_example()
```

## Summary

This tutorial covered:

1. **Point Queries** - Get soil data at specific coordinates
2. **Survey Area Queries** - Get all data for a survey area
3. **Spatial Queries** - Query by geographic regions
4. **Bulk Fetching** - Efficiently handle large datasets
5. **Hierarchical Analysis** - Work with map unit, component and horizon relationships
6. **Custom Queries** - Build complex SQL queries
7. **Error Handling** - Gracefully handle common issues
8. **Data Formats** - Export to different formats

## Next Steps

- Explore the [API Reference](api.qmd) for complete function documentation
- Check out the `examples/` directory for more use cases
- Review [common usage patterns](usage.qmd) for quick reference

## Tips for Large Datasets

1. **Use pagination**: The `fetch_by_keys()` function automatically handles large key lists
2. **Filter early**: Use WHERE clauses to reduce data transfer
3. **Select specific columns**: Don't fetch all columns if you only need a few
4. **Use async patterns**: Take advantage of concurrent processing for multiple queries
5. **Cache results**: Store frequently used data locally to reduce API calls