dc-l2-lagos-snap-normalize

Status: done
Score: 100
Duration: 2:43
Cost: 0.41¢
Model: google/gemma-4-26b-a4b-it

Map (reference ← swipe → agent)

0:00
I need to get our legacy zoning layer ready for the state portal. lagos_zoning_legacy.gpkg has inconsistent zoning_class values (mixed casing, spelling variations, and some blank entries) plus vertex precision issues that cause tiny gaps and overlaps between adjacent parcels. Can you clean up the class names to consistent canonical TitleCase values, drop any rows with blank or whitespace-only classes, fix the vertex precision problems, and produce one merged geometry per canonical zoning class? Please write the result to zoning_aggregated.gpkg, with each row carrying two columns: zoning_class and a numeric area_m2.
0:00
System info
{
  "adapter": "openrouter",
  "model": "google/gemma-4-26b-a4b-it",
  "base_url": "https://openrouter.ai/api/v1"
}
0:01
Bash ls -F
lagos_zoning_legacy.gpkg
0:08
Write /work/inspect_data.py
content · 12 lines · py
import geopandas as gpd

# Load the data to inspect columns and values
gdf = gpd.read_file('lagos_zoning_legacy.gpkg')

print("Columns:", gdf.columns.tolist())
print("\nFirst few rows:")
print(gdf.head())
print("\nUnique zoning_class values before cleaning:")
print(gdf['zoning_class'].unique())
print("\nCRS:", gdf.crs)
Wrote /work/inspect_data.py (321 chars).
0:09
Bash python /work/inspect_data.py
Columns: ['parcel_id', 'lga_source', 'zoning_class', 'area_m2', 'geometry']

First few rows:
   parcel_id  ...                                           geometry
0          1  ...  POLYGON ((540000 720000, 540010 720000, 540010...
1          2  ...  POLYGON ((540010 720000, 540020 720000, 540020...
2          3  ...  POLYGON ((540020 720000, 540030 720000, 540030...
3          4  ...  POLYGON ((540030 720000, 540040 720000, 540040...
4          5  ...  POLYGON ((540040 720000, 540050 720000, 540050...

[5 rows x 5 columns]

Unique zoning_class values before cleaning:
<ArrowStringArray>
[ 'RESIDENTIAL',  'residential',        'Resi.',        'resi.',
        'RESI.',  'Residential',        'Comm.',        'comm.',
        'COMM.',   'Commercial',   'COMMERCIAL',   'commercial',
       'Indus.',       'indus.',       'INDUS.',   'Industrial',
   'INDUSTRIAL',   'industrial',        'AGRI.', 'Agricultural',
 'AGRICULTURAL', 'agricultural',        'Agri.',        'agri.',
             '',          '   ',            nan,           '\t']
Length: 28, dtype: str

CRS: EPSG:26331
1:05
Write /work/solve.py
content · 176 lines · py
import geopandas as gpd
import pandas as pd
from shapely.geometry import MultiPolygon, Polygon
from shapely.validation import make_valid

def clean_zoning_class(val):
    if pd.isna(val):
        return None
    s = str(val).strip().lower()
    if not s:
        return None
    
    # Mapping of variations to canonical TitleCase names
    # Based on the inspection:
    # 'RESIDENTIAL', 'residential', 'Resi.', 'resi.', 'RESI.', 'Residential' -> 'Residential'
    # 'Comm.', 'comm.', 'COMM.', 'Commercial', 'COMMERCIAL', 'commercial' -> 'Commercial'
    # 'Indus.', 'indus.', 'INDUS.', 'Industrial', 'INDUSTRIAL', 'industrial' -> 'Industrial'
    # 'AGRI.', 'Agricultural', 'AGRICULTURAL', 'agricultural', 'Agri.', 'agri.' -> 'Agricultural'
    
    mapping = {
        'resi.': 'Residential',
        'residential': 'Residential',
        'res': 'Residential',
        'comm.': 'Commercial',
        'commercial': 'Commercial',
        'comm': 'Commercial',
        'indus.': 'Industrial',
        'industrial': 'Industrial',
        'indus': 'Industrial',
        'agri.': 'Agricultural',
        'agricultural': 'Agricultural',
        'agri': 'Agricultural'
    }
    
    # Try exact match in mapping, otherwise fallback to Title Case if it's not a known abbr
    # But since we want to be safe, let's look for substrings or common abbreviations
    
    for key, canonical in mapping.items():
        if s == key or s.startswith(key):
            return canonical
            
    # If no mapping matches, try title case as a fallback
    return s.title()

def solve():
    # 1. Load data
    gdf = gpd.read_file('lagos_zoning_legacy.gpkg')
    print(f"Original row count: {len(gdf)}")

    # 2. Clean zoning_class
    # Drop rows with null or whitespace-only classes
    gdf = gdf[gdf['zoning_class'].astype(str).str.strip() != '']
    gdf = gdf.dropna(subset=['zoning_class'])
    
    # Apply cleaning function
    gdf['zoning_class'] = gdf['zoning_class'].apply(clean_zoning_class)
    
    # Drop any that became None after cleaning
    gdf = gdf.dropna(subset=['zoning_class'])
    print(f"Row count after cleaning class: {len(gdf)}")
    print("New unique classes:", gdf['zoning_class'].unique())

    # 3. Fix vertex precision issues
    # A common way to fix tiny gaps/overlaps is to round the coordinates 
    # and then use buffer(0) or union to resolve geometry issues.
    # We'll round to a reasonable precision (e.g., 2 decimal places if metric)
    
    def fix_geometry(geom):
        if geom is None:
            return None
        # Rounding coordinates to fix precision gaps
        # This effectively snaps vertices to a grid
        from shapely.ops import snap
        # But simpler: round coordinates manually or use a small buffer/union
        # Let's try rounding first
        return make_valid(geom)

    # Actually, a better approach for "tiny gaps/overlaps" in GIS:
    # 1. Round coordinates to a specific precision to snap them.
    # 2. Dissolve (union) by zoning class.
    
    def round_coords(geom, precision=2):
        from shapely.geometry import mapping
        import json
        # Using a more robust way to round coords:
        if geom.is_empty:
            return geom
        
        from shapely.wkt import dumps, loads
        # This is a bit hacky but effective for rounding
        # Alternatively, use a custom function to walk the tree
        # Let's use the buffer(0) trick after rounding
        return geom

    # We will use a more standard approach:
    # 1. Ensure valid geometries
    gdf['geometry'] = gdf['geometry'].apply(lambda x: make_valid(x))
    
    # 2. To handle gaps/overlaps, we will dissolve. 
    # To snap vertices, we'll round the coordinates of the geometries.
    def snap_to_grid(geom, precision=0.01):
        from shapely.geometry import MultiPolygon, Polygon, LineString, Point
        
        def _round_coords(coords):
            return tuple(round(c, 2) for c in coords) # 2 decimal places is ~1cm in most UTMs

        if geom.is_empty:
            return geom
        
        # We can use the simple approach: buffer(0) often fixes small issues
        # but doesn't snap. Snapping is better.
        # Let's just round the coords manually via a trick
        import numpy as np
        # This is complex to do for all geom types manually, 
        # so we'll use a simplified approach: buffer(0) + dissolution
        return geom.buffer(0)

    gdf['geometry'] = gdf['geometry'].apply(lambda x: make_valid(x).buffer(0))

    # 4. Merge (Dissolve) by zoning_class
    # First, dissolve to get one geometry per class
    # We also need to calculate the total area
    
    # Dissolve and aggregate area
    # Note: Dissolving can be slow on large datasets.
    # We'll group by zoning_class and union the geometries.
    
    dissolved = gdf.dissolve(by='zoning_class', aggfunc={'area_m2': 'sum'})
    
    # After dissolving, the geometries might still have tiny gaps if they weren't perfectly snapped.
    # However, the prompt asks to "fix the vertex precision problems" AND "produce one merged geometry".
    # If we dissolve, the gaps between polygons of the same class will disappear.
    # The only thing left are gaps between DIFFERENT classes (which aren't part of the same dissolved polygon)
    # OR gaps/overlaps within the same class that weren't closed.
    
    # To truly fix vertex precision for the whole layer, we should snap all vertices to a grid first.
    # Let's refine the snap process.
    
    print("Proceeding with dissolution...")
    
    # To address the "vertex precision" specifically before dissolving:
    # A common trick is to round coordinates.
    from shapely import affinity
    
    def precision_fix(geom, decimals=2):
        # This is a bit heavy, but let's try to round the coordinates
        # A faster way: use a small buffer
        return geom.buffer(0.001).buffer(-0.001) # This is a "cleaning" trick but can shrink polygons
    
    # Actually, let's just use the dissolve. Dissolve(by='zoning_class') 
    # is the standard way to "produce one merged geometry per canonical zoning class".
    
    # Final check on columns
    # Result should have: zoning_class (index after dissolve), area_m2
    # We need to reset index to make zoning_class a column
    result = dissolved.reset_index()
    
    # Reorder/Select columns
    result = result[['zoning_class', 'area_m2', 'geometry']]
    
    # Calculate area_m2 again if the original area_m2 was just for parcels
    # The user wants the area of the merged geometry.
    # Let's recalculate area from the merged geometry to be precise.
    result['area_m2'] = result['geometry'].area
    
    # Final output selection
    result = result[['zoning_class', 'area_m2', 'geometry']]

    # Write to file
    result.to_file('zoning_aggregated.gpkg', driver="GPKG")
    print("Successfully wrote zoning_aggregated.gpkg")
    print(result)

if __name__ == "__main__":
    solve()
Wrote /work/solve.py (6983 chars).
1:12
Edit /work/solve.py
old_string and new_string are identical; no edit to make
1:54
Write /work/solve.py
content · 123 lines · py
import geopandas as gpd
import pandas as pd
from shapely.validation import make_valid
import numpy as np

def clean_zoning_class(val):
    if pd.isna(val):
        return None
    s = str(val).strip().lower()
    if not s:
        return None
    
    # Mapping of variations to canonical TitleCase names
    mapping = {
        'resi.': 'Residential',
        'residential': 'Residential',
        'res': 'Residential',
        'comm.': 'Commercial',
        'commercial': 'Commercial',
        'comm': 'Commercial',
        'indus.': 'Industrial',
        'industrial': 'Industrial',
        'indus': 'Industrial',
        'agri.': 'Agricultural',
        'agricultural': 'Agricultural',
        'agri': 'Agricultural'
    }
    
    # Check for exact match in mapping
    if s in mapping:
        return mapping[s]
        
    # Fallback for things like "RESIDENTIAL" (if not caught by mapping)
    # or other variations that might just need Title Case.
    # We check if the string is basically a known prefix
    if s.startswith('resi'): return 'Residential'
    if s.startswith('comm'): return 'Commercial'
    if s.startswith('indus'): return 'Industrial'
    if s.startswith('agri'): return 'Agricultural'

    return s.title()

def solve():
    # 1. Load data
    gdf = gpd.read_file('lagos_zoning_legacy.gpkg')
    print(f"Original row count: {len(gdf)}")

    # 2. Clean zoning_class
    # Drop rows with null or whitespace-only classes
    gdf = gdf[gdf['zoning_class'].astype(str).str.strip() != '']
    gdf = gdf.dropna(subset=['zoning_class'])
    
    # Apply cleaning function
    gdf['zoning_class'] = gdf['zoning_class'].apply(clean_zoning_class)
    
    # Drop any that became None after cleaning
    gdf = gdf.dropna(subset=['zoning_class'])
    print(f"Row count after cleaning class: {len(gdf)}")
    print("New unique classes:", gdf['zoning_class'].unique())

    # 3. Fix vertex precision problems
    # To fix tiny gaps/overlaps, we round the coordinates to a specific precision.
    # This 'snaps' vertices to a grid.
    def round_coords(geom, decimals=2):
        if geom.is_empty:
            return geom
        # Use a transformation to round coordinates
        # A simple way to do this is to use a small buffer or manual coordinate rounding
        # But for many geometries, let's use the coordinate rounding approach
        def _round_point(x, y):
            return (round(x, decimals), round(y, decimals))

        if geom.geom_type == 'Polygon':
            return Polygon([_round_point(x, y) for x, y in geom.exterior.coords]) + \
                   MultiPolygon([Polygon([_round_point(x, y) for x, y in poly.exterior.coords]) 
                                 for poly in geom.interiors]) # This is wrong for interiors
        # Using a better way: just use a tiny buffer to close gaps before dissolving
        return geom

    # Let's try a simpler approach for "vertex precision":
    # 1. Make valid
    # 2. Buffer(0) to fix topology
    # 3. Use a small buffer to merge touching edges if they have gaps
    # Actually, the most standard way to handle this in GIS for 'cleaning' is to 
    # snap the layer to a grid. Let's use a simple rounding function.
    
    from shapely.ops import transform

    def snap_to_grid(geom, precision=0.01):
        def _snap(x, y):
            return (round(x / precision) * precision, round(y / precision) * precision)
        return transform(_snap, geom)

    print("Snapping to grid...")
    gdf['geometry'] = gdf['geometry'].apply(lambda x: snap_to_grid(make_valid(x), 0.01))

    # 4. Merge (Dissolve) by zoning_class
    print("Dissolving by zoning_class...")
    # We dissolve to merge all geometries of the same class into one
    dissolved = gdf.dissolve(by='zoning_class')
    
    # After dissolve, ensure we have one geometry per class and calculate area
    # Dissolve by default takes the first 'area_m2' or can be aggregated.
    # The user wants the area of the merged geometry.
    
    # Reset index to make 'zoning_class' a column again
    result = dissolved.reset_index()
    
    # Calculate the true area of the merged geometry
    result['area_m2'] = result['geometry'].area
    
    # Final column selection: zoning_class, area_m2, and geometry
    # (geometry is included as it's a spatial file)
    result = result[['zoning_class', 'area_m2', 'geometry']]

    # Write to file
    result.to_file('zoning_aggregated.gpkg', driver="GPKG")
    print("Successfully wrote zoning_aggregated.gpkg")
    print(result)

if __name__ == "__main__":
    solve()
Wrote /work/solve.py (4565 chars).
1:55
Bash python /work/solve.py
Original row count: 10080
Row count after cleaning class: 10030
New unique classes: <ArrowStringArray>
['Residential', 'Commercial', 'Industrial', 'Agricultural']
Length: 4, dtype: str
Snapping to grid...
Dissolving by zoning_class...
Successfully wrote zoning_aggregated.gpkg
   zoning_class   area_m2                                           geometry
0  Agricultural  250000.0  GEOMETRYCOLLECTION (LINESTRING (542515 720500,...
1    Commercial  250000.0  GEOMETRYCOLLECTION (LINESTRING (542505 720500,...
2    Industrial  250000.0  GEOMETRYCOLLECTION (LINESTRING (542510 720500,...
3   Residential  250000.0  GEOMETRYCOLLECTION (LINESTRING (542500 720500,...
2:24
Write /work/solve.py
content · 81 lines · py
import geopandas as gpd
import pandas as pd
from shapely.validation import make_valid
from shapely.ops import transform

def clean_zoning_class(val):
    if pd.isna(val):
        return None
    s = str(val).strip().lower()
    if not s:
        return None
    
    mapping = {
        'resi.': 'Residential',
        'residential': 'Residential',
        'res': 'Residential',
        'comm.': 'Commercial',
        'commercial': 'Commercial',
        'comm': 'Commercial',
        'indus.': 'Industrial',
        'industrial': 'Industrial',
        'indus': 'Industrial',
        'agri.': 'Agricultural',
        'agricultural': 'Agricultural',
        'agri': 'Agricultural'
    }
    
    if s in mapping:
        return mapping[s]
    
    if s.startswith('resi'): return 'Residential'
    if s.startswith('comm'): return 'Commercial'
    if s.startswith('indus'): return 'Industrial'
    if s.startswith('agri'): return 'Agricultural'

    return s.title()

def solve():
    # 1. Load data
    gdf = gpd.read_file('lagos_zoning_legacy.gpkg')
    print(f"Original row count: {len(gdf)}")

    # 2. Clean zoning_class
    gdf = gdf[gdf['zoning_class'].astype(str).str.strip() != '']
    gdf = gdf.dropna(subset=['zoning_class'])
    gdf['zoning_class'] = gdf['zoning_class'].apply(clean_zoning_class)
    gdf = gdf.dropna(subset=['zoning_class'])
    print(f"Row count after cleaning class: {len(gdf)}")

    # 3. Fix vertex precision problems
    # Snapping to a 0.01 grid to resolve gaps/overlaps
    def snap_to_grid(geom, precision=0.01):
        def _snap(x, y):
            return (round(x / precision) * precision, round(y / precision) * precision)
        return transform(_snap, geom)

    print("Fixing geometry precision...")
    # Make valid, then snap to grid, then buffer(0) to clean up any topology issues introduced by snapping
    gdf['geometry'] = gdf['geometry'].apply(lambda x: make_valid(x))
    gdf['geometry'] = gdf['geometry'].apply(lambda x: snap_to_grid(x, 0.01))
    gdf['geometry'] = gdf['geometry'].apply(lambda x: x.buffer(0))

    # 4. Merge (Dissolve) by zoning_class
    print("Dissolving by zoning_class...")
    dissolved = gdf.dissolve(by='zoning_class')
    
    result = dissolved.reset_index()
    # Recalculate area from the dissolved geometry
    result['area_m2'] = result['geometry'].area
    
    # Final column selection: zoning_class, area_m2, and geometry
    result = result[['zoning_class', 'area_m2', 'geometry']]

    # Write to file
    result.to_file('zoning_aggregated.gpkg', driver="GPKG")
    print("Successfully wrote zoning_aggregated.gpkg")
    print(result)

if __name__ == "__main__":
    solve()
Wrote /work/solve.py (2663 chars).
2:26
Bash python /work/solve.py
Original row count: 10080
Row count after cleaning class: 10030
Fixing geometry precision...
Dissolving by zoning_class...
Successfully wrote zoning_aggregated.gpkg
   zoning_class   area_m2                                           geometry
0  Agricultural  250000.0  POLYGON ((540500 720580, 540500 720590, 540500...
1    Commercial  250000.0  POLYGON ((540500 720080, 540500 720090, 540500...
2    Industrial  250000.0  POLYGON ((540000 720580, 540000 720590, 540000...
3   Residential  250000.0  POLYGON ((540000 720080, 540000 720090, 540000...
2:42