Buffering and Thiessen Polygons

Advanced Spatial Analysis

Video Locked

Please log in to watch this video

Log In
Chapter Info
Course The Ultimate PostGIS course
Module Advanced Spatial Analysis
Chapter Buffering and Thiessen Polygons

Chapter Content

Spatial influence zones are fundamental concepts in GIS analysis, helping us understand how features affect their surrounding areas. PostGIS provides powerful tools for creating buffer zones and Thiessen (Voronoi) polygons, enabling sophisticated proximity analysis and service area planning. This comprehensive guide covers both techniques with practical applications for real-world spatial problems.

Understanding Spatial Influence Zones

Spatial influence zones help us model how geographic features impact their surroundings. Two primary methods for defining these zones are:

Method Description Use Case Key Function
Buffers Areas around features at fixed distances Service coverage, impact zones ST_Buffer
Thiessen Polygons Zones where every location is closest to one input point Service territories, catchment areas ST_VoronoiPolygons

Applications of Influence Zones

  • Urban Planning: School catchment areas, service allocation
  • Emergency Services: Response zones, coverage analysis
  • Telecommunications: Tower coverage areas
  • Environmental Analysis: Pollution impact zones
  • Retail: Market territories, customer catchments
  • Agriculture: Irrigation zones, field management

Setting Up Sample Data

Let's create a realistic dataset of schools in a city to demonstrate both buffering and Thiessen polygon techniques:

-- Create schools table
CREATE TABLE schools (
  id SERIAL PRIMARY KEY,
  name TEXT,
  school_type TEXT,
  capacity INTEGER,
  geom GEOMETRY(Point, 4326)
);

-- Insert sample schools with realistic coordinates (Delhi area)
INSERT INTO schools (name, school_type, capacity, geom) VALUES
('Delhi Public School', 'Private', 1200, ST_SetSRID(ST_Point(77.20, 28.60), 4326)),
('Government Senior Secondary', 'Government', 800, ST_SetSRID(ST_Point(77.25, 28.62), 4326)),
('St. Mary''s Convent', 'Private', 600, ST_SetSRID(ST_Point(77.28, 28.60), 4326)),
('Kendriya Vidyalaya', 'Central', 1000, ST_SetSRID(ST_Point(77.22, 28.65), 4326)),
('Modern School', 'Private', 900, ST_SetSRID(ST_Point(77.26, 28.58), 4326)),
('Sarvodaya Bal Vidyalaya', 'Government', 700, ST_SetSRID(ST_Point(77.24, 28.64), 4326));

-- Create spatial index
CREATE INDEX idx_schools_geom ON schools USING GIST (geom);

-- Add additional tables for comprehensive analysis
CREATE TABLE residential_areas (
  id SERIAL PRIMARY KEY,
  area_name TEXT,
  population INTEGER,
  geom GEOMETRY(POLYGON, 4326)
);

-- Sample residential areas
INSERT INTO residential_areas (area_name, population, geom) VALUES
('Sector A', 5000, ST_GeomFromText('POLYGON((77.19 28.59, 77.21 28.59, 77.21 28.61, 77.19 28.61, 77.19 28.59))', 4326)),
('Sector B', 7500, ST_GeomFromText('POLYGON((77.23 28.61, 77.25 28.61, 77.25 28.63, 77.23 28.63, 77.23 28.61))', 4326)),
('Sector C', 6200, ST_GeomFromText('POLYGON((77.27 28.59, 77.29 28.59, 77.29 28.61, 77.27 28.61, 77.27 28.59))', 4326));

Creating Buffer Zones

ST_Buffer creates circular or polygonal zones around geometries at specified distances. The key is understanding the difference between geometry and geography types for accurate distance calculations.

Basic Buffer Creation

-- Approximate buffer using geometry (degrees)
SELECT 
  name, 
  school_type,
  ST_Buffer(geom, 0.005) AS buffer_geom_degrees
FROM schools;

-- Accurate buffer using geography (meters)
SELECT 
  name, 
  school_type,
  capacity,
  ST_Buffer(geom::geography, 500)::geometry AS buffer_500m,
  ST_Buffer(geom::geography, 1000)::geometry AS buffer_1km,
  ST_Buffer(geom::geography, 2000)::geometry AS buffer_2km
FROM schools;

Multi-Distance Buffer Analysis

-- Create multiple buffer rings for each school
WITH buffer_distances AS (
  SELECT unnest(ARRAY[500, 1000, 1500, 2000]) AS distance_m
),
school_buffers AS (
  SELECT 
    s.id,
    s.name,
    s.school_type,
    bd.distance_m,
    ST_Buffer(s.geom::geography, bd.distance_m)::geometry AS buffer_geom,
    ST_Area(ST_Buffer(s.geom::geography, bd.distance_m)) / 1000000 AS buffer_area_km2
  FROM schools s
  CROSS JOIN buffer_distances bd
)
SELECT 
  name,
  school_type,
  distance_m,
  ROUND(buffer_area_km2::numeric, 2) AS area_km2
FROM school_buffers
ORDER BY name, distance_m;

Buffer-Based Service Coverage Analysis

-- Find residential areas within school catchment zones
SELECT 
  s.name AS school_name,
  s.school_type,
  s.capacity,
  r.area_name,
  r.population,
  ST_Area(ST_Intersection(
    ST_Buffer(s.geom::geography, 1000)::geometry, 
    r.geom
  )) AS coverage_area,
  CASE 
    WHEN ST_Within(r.geom, ST_Buffer(s.geom::geography, 1000)::geometry) THEN 'Fully Covered'
    WHEN ST_Intersects(r.geom, ST_Buffer(s.geom::geography, 1000)::geometry) THEN 'Partially Covered'
    ELSE 'Not Covered'
  END AS coverage_status
FROM schools s
CROSS JOIN residential_areas r
WHERE ST_Intersects(r.geom, ST_Buffer(s.geom::geography, 1000)::geometry)
ORDER BY s.name, r.area_name;

Creating Thiessen (Voronoi) Polygons

Thiessen polygons divide space into regions where every location is closest to one input point. This is perfect for service territory analysis and resource allocation.

Basic Voronoi Polygon Creation

-- Create Voronoi polygons from school points
SELECT 
  (ST_Dump(ST_VoronoiPolygons(ST_Collect(geom)))).geom AS voronoi_geom
FROM schools;

-- Create Voronoi polygons with extent boundary
SELECT 
  (ST_Dump(ST_VoronoiPolygons(
    ST_Collect(geom), 
    0.0, 
    ST_Envelope(ST_Collect(geom))
  ))).geom AS bounded_voronoi
FROM schools;

Labeled Voronoi Polygons

-- Create Voronoi polygons with school information
WITH voronoi_raw AS (
  SELECT 
    row_number() OVER () AS voronoi_id,
    (ST_Dump(ST_VoronoiPolygons(ST_Collect(geom)))).geom AS voronoi_geom
  FROM schools
),
schools_numbered AS (
  SELECT 
    row_number() OVER () AS school_id,
    id,
    name,
    school_type,
    capacity,
    geom
  FROM schools
)
SELECT 
  sn.id,
  sn.name,
  sn.school_type,
  sn.capacity,
  vr.voronoi_geom,
  ST_Area(vr.voronoi_geom) AS territory_area_degrees,
  ST_Area(vr.voronoi_geom::geography) / 1000000 AS territory_area_km2
FROM voronoi_raw vr
JOIN schools_numbered sn ON vr.voronoi_id = sn.school_id;

Advanced Voronoi Analysis

-- Analyze population served by each school's Voronoi territory
WITH school_territories AS (
  SELECT 
    row_number() OVER () AS territory_id,
    (ST_Dump(ST_VoronoiPolygons(ST_Collect(geom)))).geom AS territory_geom
  FROM schools
),
schools_with_territories AS (
  SELECT 
    s.*,
    st.territory_geom,
    row_number() OVER () AS rn
  FROM schools s
  CROSS JOIN school_territories st
),
final_territories AS (
  SELECT *
  FROM schools_with_territories
  WHERE id = rn
)
SELECT 
  ft.name AS school_name,
  ft.school_type,
  ft.capacity,
  SUM(ra.population) AS total_population_served,
  COUNT(ra.id) AS residential_areas_served,
  ROUND(ST_Area(ft.territory_geom::geography) / 1000000, 2) AS territory_km2,
  CASE 
    WHEN SUM(ra.population) > ft.capacity THEN 'Over Capacity'
    WHEN SUM(ra.population) * 0.8 > ft.capacity THEN 'Near Capacity'
    ELSE 'Under Capacity'
  END AS capacity_status
FROM final_territories ft
LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, ft.territory_geom)
GROUP BY ft.id, ft.name, ft.school_type, ft.capacity, ft.territory_geom
ORDER BY total_population_served DESC;

Comparing Buffer vs Thiessen Approaches

Aspect Buffer Zones Thiessen Polygons
Shape Circular (uniform distance) Irregular (proximity-based)
Overlap Can overlap No overlap, complete coverage
Distance Fixed distance from source Variable distance to boundary
Use Case Service coverage, impact zones Service territories, market areas
Computation Simple and fast More complex, slower

Combined Analysis

-- Compare buffer coverage vs Voronoi territories
WITH school_buffers AS (
  SELECT 
    id,
    name,
    ST_Buffer(geom::geography, 1000)::geometry AS buffer_1km
  FROM schools
),
school_voronoi AS (
  SELECT 
    row_number() OVER () AS territory_id,
    (ST_Dump(ST_VoronoiPolygons(ST_Collect(geom)))).geom AS voronoi_geom
  FROM schools
),
schools_comparison AS (
  SELECT 
    s.id,
    s.name,
    s.school_type,
    sb.buffer_1km,
    sv.voronoi_geom,
    ST_Area(sb.buffer_1km::geography) / 1000000 AS buffer_area_km2,
    ST_Area(sv.voronoi_geom::geography) / 1000000 AS voronoi_area_km2
  FROM schools s
  JOIN school_buffers sb ON s.id = sb.id
  JOIN school_voronoi sv ON s.id = sv.territory_id
)
SELECT 
  name,
  school_type,
  ROUND(buffer_area_km2, 2) AS buffer_area_km2,
  ROUND(voronoi_area_km2, 2) AS voronoi_area_km2,
  ROUND(voronoi_area_km2 / buffer_area_km2, 2) AS area_ratio,
  CASE 
    WHEN voronoi_area_km2 > buffer_area_km2 THEN 'Voronoi Larger'
    ELSE 'Buffer Larger'
  END AS larger_area
FROM schools_comparison
ORDER BY area_ratio DESC;

Real-World Applications

Emergency Services Planning

-- Create fire station coverage analysis
CREATE TABLE fire_stations (
  id SERIAL PRIMARY KEY,
  station_name TEXT,
  response_time_target INTEGER, -- minutes
  geom GEOMETRY(POINT, 4326)
);

-- Response time buffers (assuming 60 km/h average speed)
WITH response_buffers AS (
  SELECT 
    station_name,
    response_time_target,
    -- Convert response time to distance (km/h * minutes/60)
    ST_Buffer(geom::geography, (60.0 * response_time_target / 60.0) * 1000)::geometry AS coverage_area
  FROM fire_stations
)
SELECT 
  rb.station_name,
  rb.response_time_target,
  COUNT(ra.id) AS areas_covered,
  SUM(ra.population) AS population_covered
FROM response_buffers rb
LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, rb.coverage_area)
GROUP BY rb.station_name, rb.response_time_target;

Retail Market Analysis

-- Store catchment analysis using Thiessen polygons
CREATE TABLE retail_stores (
  id SERIAL PRIMARY KEY,
  store_name TEXT,
  store_type TEXT,
  geom GEOMETRY(POINT, 4326)
);

-- Market territory analysis
WITH store_territories AS (
  SELECT 
    row_number() OVER () AS territory_id,
    (ST_Dump(ST_VoronoiPolygons(ST_Collect(geom)))).geom AS market_area
  FROM retail_stores
),
market_analysis AS (
  SELECT 
    rs.store_name,
    rs.store_type,
    st.market_area,
    SUM(ra.population) AS potential_customers,
    ST_Area(st.market_area::geography) / 1000000 AS market_area_km2
  FROM retail_stores rs
  JOIN store_territories st ON rs.id = st.territory_id
  LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, st.market_area)
  GROUP BY rs.id, rs.store_name, rs.store_type, st.market_area
)
SELECT 
  store_name,
  store_type,
  potential_customers,
  ROUND(market_area_km2, 2) AS market_area_km2,
  ROUND(potential_customers / market_area_km2, 0) AS customer_density_per_km2
FROM market_analysis
ORDER BY potential_customers DESC;

Advanced Techniques

Weighted Voronoi Diagrams

-- Create capacity-weighted school territories
-- (Larger schools get larger territories)
WITH weighted_points AS (
  SELECT 
    id,
    name,
    capacity,
    -- Create multiple points based on capacity for weighting effect
    ST_Collect(
      ARRAY(
        SELECT ST_Translate(geom, random()*0.001-0.0005, random()*0.001-0.0005)
        FROM generate_series(1, GREATEST(1, capacity/200))
      )
    ) AS weighted_geom
  FROM schools
)
SELECT 
  name,
  capacity,
  (ST_Dump(ST_VoronoiPolygons(ST_Collect(weighted_geom)))).geom AS weighted_territory
FROM weighted_points;

Multi-Ring Buffer Analysis

-- Create concentric service zones
WITH service_rings AS (
  SELECT 
    s.name,
    s.school_type,
    -- Inner ring (0-500m)
    ST_Buffer(s.geom::geography, 500)::geometry AS inner_ring,
    -- Middle ring (500-1000m) 
    ST_Difference(
      ST_Buffer(s.geom::geography, 1000)::geometry,
      ST_Buffer(s.geom::geography, 500)::geometry
    ) AS middle_ring,
    -- Outer ring (1000-1500m)
    ST_Difference(
      ST_Buffer(s.geom::geography, 1500)::geometry,
      ST_Buffer(s.geom::geography, 1000)::geometry
    ) AS outer_ring
  FROM schools s
)
SELECT 
  sr.name,
  sr.school_type,
  'Inner (0-500m)' AS ring_type,
  COUNT(ra.id) AS residential_areas,
  SUM(ra.population) AS population
FROM service_rings sr
LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, sr.inner_ring)
GROUP BY sr.name, sr.school_type

UNION ALL

SELECT 
  sr.name,
  sr.school_type,
  'Middle (500-1000m)' AS ring_type,
  COUNT(ra.id) AS residential_areas,
  SUM(ra.population) AS population
FROM service_rings sr
LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, sr.middle_ring)
GROUP BY sr.name, sr.school_type

UNION ALL

SELECT 
  sr.name,
  sr.school_type,
  'Outer (1000-1500m)' AS ring_type,
  COUNT(ra.id) AS residential_areas,
  SUM(ra.population) AS population
FROM service_rings sr
LEFT JOIN residential_areas ra ON ST_Intersects(ra.geom, sr.outer_ring)
GROUP BY sr.name, sr.school_type

ORDER BY name, ring_type;

Performance Optimization

Technique Description When to Use
Spatial Indexes GIST indexes on geometry columns Always for spatial operations
Geography vs Geometry Use geography for accurate distances When precision matters
Simplified Geometries Use ST_Simplify for complex polygons Large-scale analysis
Bounding Box Filters Pre-filter with && operator Large datasets
Batch Processing Process in smaller chunks Very large datasets

Best Practices

  1. Choose appropriate coordinate systems: Use geography for global analysis, local projections for regional work
  2. Validate input geometries: Use ST_IsValid() before complex operations
  3. Consider edge effects: Voronoi polygons extend to infinity - clip to study area
  4. Index spatial columns: Create GIST indexes for better performance
  5. Test with sample data: Verify results with known cases before full analysis
  6. Document assumptions: Record distance units, coordinate systems, and methodology
  7. Handle edge cases: Account for boundary effects and data gaps

Common Pitfalls and Solutions

Pitfall Problem Solution
Incorrect distance units Using degrees instead of meters Convert to geography type for metric distances
Unbounded Voronoi polygons Infinite polygons at edges Provide extent parameter to ST_VoronoiPolygons
Performance issues Slow queries on large datasets Use spatial indexes and appropriate filtering
Overlapping buffers Double-counting in analysis Use ST_Union to merge overlapping areas
Invalid geometries Operations fail on invalid input Use ST_MakeValid() to fix geometries