Files
maps-saas/scripts/enrich_iraq_comprehensive.py
T

524 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Comprehensive Iraq Places Enrichment & Deduplication Script.
Enriches `places_iraq` table by harvesting and deduplicating:
1. Overture Maps Places (`overture_place`)
2. OpenStreetMap Named Points (`planet_osm_point`)
3. OpenStreetMap Named Polygons (`planet_osm_polygon`)
4. Administrative boundaries & Governorates linking
5. Concurrently refreshes `unified_search_index`
"""
import os
import sys
import math
import time
import argparse
from collections import defaultdict
def haversine(lat1, lon1, lat2, lon2):
R = 6371000 # meters
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def normalize_text(t):
if not t:
return ''
s = t.strip()
s = s.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي')
return ' '.join(s.split()).lower()
def clean_sql_str(val):
if val is None:
return 'NULL'
s = str(val).strip().replace("'", "''")
return f"'{s}'"
# Category Translations
OVERTURE_CAT_MAP = {
'restaurant': 'مطعم',
'cafe': 'مقهى وكافيه',
'coffee_shop': 'مقهى وكوفي شوب',
'casual_eatery': 'وجبات سريعة ومأكولات خفيفة',
'bakery': 'مخبز ومعجنات',
'food_and_beverage_store': 'بقالة ومواد غذائية',
'supermarket': 'سوبرماركت وهايبرماركت',
'fashion_and_apparel_store': 'متجر ألبسة وأزياء',
'shoe_store': 'متجر أحذية',
'jewelry_store': 'مجوهرات وحلي',
'electronics_store': 'متجر إلكترونيات وأجهزة',
'mobile_phone_store': 'متجر هواتف ذكية',
'hardware_home_and_garden_store': 'متجر مواد بناء ومستلزمات منزلية',
'home_service': 'خدمات منزلية وصيانة',
'personal_or_beauty_service': 'صالون ومركز تجميل',
'hair_salon': 'صالون حلاقة وتزيين',
'hospital': 'مستشفى / مركز طبي',
'dental_clinic': 'عيادة طب أسنان',
'specialized_health_care': 'عيادة استشارية متخصصة',
'pharmacy': 'صيدلية',
'hotel': 'فندق وإقامة',
'place_of_learning': 'مؤسسة تعليمية',
'school': 'مدرسة',
'college_university': 'كلية / جامعة',
'historic_site': 'موقع تاريخي وأثري',
'religious_organization': 'دار عبادة ومؤسسة دينية',
'mosque': 'مسجد / جامع',
'real_estate_service': 'مكتب عقاري',
'professional_service': 'خدمات مهنية واستشارية',
'travel_service': 'مكتب سياحة وسفر',
'corporate_or_business_office': 'مكتب شركة ومؤسسة أعمال',
'car_repair': 'صيانة وتصليح سيارات',
'car_dealership': 'معرض ووكالة سيارات',
'fuel_station': 'محطة وقود',
'bank': 'مصرف / بنك',
'government_office': 'دائرة حكومية ورسمية',
'law_firm': 'مكتب محاماة واستشارات قانونية',
'sports_club': 'نادي ومجمع رياضي',
'park': 'منتزه وحديقة عامة',
}
OSM_CAT_MAP = {
'village': 'قرية / تجمع سكاني',
'town': 'بلدة / قضاء',
'city': 'مدينة',
'suburb': 'حي / ضاحية',
'neighbourhood': 'حي سكني',
'hamlet': 'قرية صغيرة',
'locality': 'منطقة / موضع',
'school': 'مدرسة / تعليم',
'college': 'كلية',
'university': 'جامعة',
'kindergarten': 'روضة أطفال',
'supermarket': 'سوبرماركت ومواد غذائية',
'convenience': 'محل بقالة',
'bakery': 'مخبز',
'butcher': 'ملحمة وقصابة',
'mall': 'مركز تسوق ومول',
'place_of_worship': 'مسجد / دار عبادة',
'mosque': 'مسجد / جامع',
'clinic': 'مركز صحي وعيادة',
'pharmacy': 'صيدلية',
'hospital': 'مستشفى',
'doctors': 'عيادة طبيب',
'dentist': 'عيادة أسنان',
'restaurant': 'مطعم',
'cafe': 'مقهى وكافيه',
'fast_food': 'وجبات سريعة',
'fuel': 'محطة وقود',
'bank': 'مصرف / بنك',
'atm': 'صراف آلي',
'bureau_de_change': 'مكتب صرافة وتحويل مالي',
'police': 'مركز شرطة وأمن',
'fire_station': 'دفاع مدني / إطفاء',
'post_office': 'مكتب بريد',
'attraction': 'معلم سياحي',
'hotel': 'فندق',
'guest_house': 'نُزل واستضافة',
'museum': 'متحف / تراث',
'park': 'حديقة ومنتزه',
'stadium': 'ملعب / مجمع رياضي',
'sports_centre': 'مركز رياضي',
'car_repair': 'تصليح سيارات',
'car_wash': 'مغسلة سيارات',
'company': 'شركة ومؤسسة',
'government': 'دائرة حكومية',
'mobile_phone': 'متجر هواتف ونقالات',
'clothes': 'متجر ألبسة',
'shoes': 'متجر أحذية',
'hairdresser': 'صالون حلاقة',
'optician': 'بصريات ونظارات',
'hardware': 'مواد بناء وتجهيزات',
}
def map_overture_category(cat_str):
if not cat_str:
return 'مكان ونقطة اهتمام'
clean = cat_str.strip().lower()
return OVERTURE_CAT_MAP.get(clean, 'مكان عام وتجاري')
def map_osm_category(val):
if not val:
return 'نقطة اهتمام'
clean = str(val).strip().lower()
return OSM_CAT_MAP.get(clean, 'مكان عام')
def get_grid_cell(lat, lon, cell_size=0.01):
# ~1.1km cell size
return (int(lat / cell_size), int(lon / cell_size))
def is_duplicate(name_norm, lat, lon, grid, max_dist_m=60.0):
# 60 meters approx threshold in degrees
d_lat_thresh = 0.0006
d_lon_thresh = 0.0007
c_x, c_y = get_grid_cell(lat, lon)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
cell = (c_x + dx, c_y + dy)
if cell in grid:
for existing_name, ex_lat, ex_lon in grid[cell]:
if abs(lat - ex_lat) > d_lat_thresh or abs(lon - ex_lon) > d_lon_thresh:
continue
dist = haversine(lat, lon, ex_lat, ex_lon)
if dist <= max_dist_m:
# Check name similarity
if (name_norm == existing_name or
name_norm in existing_name or
existing_name in name_norm):
return True
return False
def add_to_grid(name_norm, lat, lon, grid):
cell = get_grid_cell(lat, lon)
grid[cell].append((name_norm, lat, lon))
def main():
parser = argparse.ArgumentParser(description="Enrich Iraq places comprehensively from Overture & OSM.")
parser.add_argument("--db-host", default="127.0.0.1", help="Database host")
parser.add_argument("--db-port", type=int, default=5432, help="Database port")
parser.add_argument("--db-user", default="mapuser", help="Database user")
parser.add_argument("--db-pass", default="mappass", help="Database password")
parser.add_argument("--db-name", default="mapdb", help="Database name")
parser.add_argument("--dry-run", action="store_true", help="Perform extraction and deduplication only without inserting.")
args = parser.parse_args()
import pg8000.native
print("=" * 70)
print("🇮🇶 SIRO Maps — Comprehensive Iraq Places Enrichment & Deduplication")
print("=" * 70)
print(f"🔌 Connecting to PostgreSQL at {args.db_host}:{args.db_port} ({args.db_name})...")
con = pg8000.native.Connection(
user=args.db_user,
password=args.db_pass,
host=args.db_host,
port=args.db_port,
database=args.db_name
)
t0 = time.time()
grid = defaultdict(list)
# 1. Load existing places into spatial hash grid
print("\n📦 Step 1: Loading existing places_iraq into spatial index for deduplication...")
existing = con.run("""
SELECT id, name, latitude, longitude
FROM places_iraq
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
""")
print(f" -> Loaded {len(existing):,} existing places.")
for row in existing:
pid, name, lat, lon = row
lat = float(lat)
lon = float(lon)
norm_name = normalize_text(name)
add_to_grid(norm_name, lat, lon, grid)
print(f" ✓ Spatial grid built with {sum(len(v) for v in grid.values()):,} items.")
# 2. Harvest from overture_place
print("\n🗺️ Step 2: Harvesting from Overture Maps Places (overture_place)...")
overture_rows = con.run("""
SELECT
name_primary,
basic_category,
addresses,
confidence,
ST_Y(ST_Centroid(location::geometry)) as lat,
ST_X(ST_Centroid(location::geometry)) as lon
FROM overture_place
WHERE name_primary IS NOT NULL
AND TRIM(name_primary) != ''
AND ST_Within(location::geometry, ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326));
""")
print(f" -> Found {len(overture_rows):,} candidates in overture_place for Iraq.")
overture_to_insert = []
overture_dups = 0
for r in overture_rows:
name = str(r[0]).strip()
cat_str = r[1]
addr_list = r[2]
confidence = float(r[3] or 0.8)
lat = float(r[4])
lon = float(r[5])
norm_name = normalize_text(name)
if len(norm_name) < 2:
continue
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
overture_dups += 1
continue
# Extract address details if available
city = 'العراق'
addr_text = None
if addr_list and isinstance(addr_list, list) and len(addr_list) > 0:
a = addr_list[0]
if isinstance(a, dict):
city = a.get('locality') or a.get('region') or 'العراق'
addr_text = a.get('freeform')
category = map_overture_category(cat_str)
pop_score = min(100, int(confidence * 60 + 20))
item = {
'name': name,
'name_ar': name,
'name_en': name,
'category': category,
'city': city,
'address': addr_text or f"{city}, العراق",
'lat': lat,
'lon': lon,
'source': 'overture_maps',
'popularity_score': pop_score
}
overture_to_insert.append(item)
add_to_grid(norm_name, lat, lon, grid)
print(f" ✓ Overture Processing Complete:")
print(f" - Duplicates Filtered: {overture_dups:,}")
print(f" - Net New Places: {len(overture_to_insert):,}")
# 3. Harvest from planet_osm_point
print("\n📍 Step 3: Harvesting from OpenStreetMap Named Points (planet_osm_point)...")
osm_point_rows = con.run("""
SELECT
name,
COALESCE(amenity, shop, tourism, historic, place, leisure, office, building, highway, "natural") as cat,
ST_Y(ST_Transform(way, 4326)) as lat,
ST_X(ST_Transform(way, 4326)) as lon
FROM planet_osm_point
WHERE name IS NOT NULL
AND TRIM(name) != ''
AND way && ST_Transform(ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326), 3857);
""")
print(f" -> Found {len(osm_point_rows):,} candidates in planet_osm_point for Iraq.")
osm_point_to_insert = []
osm_point_dups = 0
for r in osm_point_rows:
name = str(r[0]).strip()
cat_raw = r[1]
lat = float(r[2])
lon = float(r[3])
# Bounds check
if not (28.8 <= lat <= 37.5 and 38.8 <= lon <= 48.8):
continue
norm_name = normalize_text(name)
if len(norm_name) < 2:
continue
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
osm_point_dups += 1
continue
category = map_osm_category(cat_raw)
pop_score = 65 if cat_raw in ('village', 'town', 'city', 'hospital', 'university', 'mosque') else 40
item = {
'name': name,
'name_ar': name,
'name_en': name,
'category': category,
'city': 'العراق',
'address': f"{category}, العراق",
'lat': lat,
'lon': lon,
'source': 'osm_point',
'popularity_score': pop_score
}
osm_point_to_insert.append(item)
add_to_grid(norm_name, lat, lon, grid)
print(f" ✓ OSM Points Processing Complete:")
print(f" - Duplicates Filtered: {osm_point_dups:,}")
print(f" - Net New Places: {len(osm_point_to_insert):,}")
# 4. Harvest from planet_osm_polygon (POIs)
print("\n🏛️ Step 4: Harvesting from OpenStreetMap Named Polygons (planet_osm_polygon)...")
osm_poly_rows = con.run("""
SELECT
name,
COALESCE(amenity, shop, tourism, historic, place, leisure, building, landuse) as cat,
ST_Y(ST_Centroid(ST_Transform(way, 4326))) as lat,
ST_X(ST_Centroid(ST_Transform(way, 4326))) as lon
FROM planet_osm_polygon
WHERE name IS NOT NULL
AND TRIM(name) != ''
AND (amenity IS NOT NULL OR shop IS NOT NULL OR tourism IS NOT NULL OR historic IS NOT NULL
OR place IS NOT NULL OR leisure IS NOT NULL OR landuse IN ('commercial', 'retail', 'industrial', 'cemetery', 'religious'))
AND way && ST_Transform(ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326), 3857);
""")
print(f" -> Found {len(osm_poly_rows):,} candidates in planet_osm_polygon for Iraq.")
osm_poly_to_insert = []
osm_poly_dups = 0
for r in osm_poly_rows:
name = str(r[0]).strip()
cat_raw = r[1]
lat = float(r[2])
lon = float(r[3])
if not (28.8 <= lat <= 37.5 and 38.8 <= lon <= 48.8):
continue
norm_name = normalize_text(name)
if len(norm_name) < 2:
continue
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
osm_poly_dups += 1
continue
category = map_osm_category(cat_raw)
pop_score = 70 if cat_raw in ('hospital', 'university', 'mall', 'stadium', 'attraction') else 45
item = {
'name': name,
'name_ar': name,
'name_en': name,
'category': category,
'city': 'العراق',
'address': f"{category}, العراق",
'lat': lat,
'lon': lon,
'source': 'osm_polygon',
'popularity_score': pop_score
}
osm_poly_to_insert.append(item)
add_to_grid(norm_name, lat, lon, grid)
print(f" ✓ OSM Polygons Processing Complete:")
print(f" - Duplicates Filtered: {osm_poly_dups:,}")
print(f" - Net New Places: {len(osm_poly_to_insert):,}")
# Total new places to insert
all_new_places = overture_to_insert + osm_point_to_insert + osm_poly_to_insert
print(f"\n🌟 Total Net New Places to Insert: {len(all_new_places):,}")
if args.dry_run:
print("💡 Dry run requested. Exiting without database insertion.")
return
# 5. Database Batch Insertion
print(f"\n🚀 Step 5: Inserting {len(all_new_places):,} places into places_iraq...")
t_insert = time.time()
batch_size = 2500
total_inserted = 0
con.run("BEGIN;")
for i in range(0, len(all_new_places), batch_size):
batch = all_new_places[i:i + batch_size]
values = []
for r in batch:
c_name = clean_sql_str(r['name'])
c_name_ar = clean_sql_str(r['name_ar'])
c_name_en = clean_sql_str(r['name_en'])
c_cat = clean_sql_str(r['category'])
c_city = clean_sql_str(r['city'])
c_addr = clean_sql_str(r['address'])
c_src = clean_sql_str(r['source'])
lat = r['lat']
lon = r['lon']
pop = r['popularity_score']
line = (
f"({c_name}, {c_name_ar}, {c_name_en}, {lat:.7f}, {lon:.7f}, {c_cat}, "
f"{c_city}, {c_addr}, {c_src}, {pop}, "
f"ST_SetSRID(ST_MakePoint({lon:.7f}, {lat:.7f}), 4326))"
)
values.append(line)
sql = (
"INSERT INTO places_iraq ("
" name, name_ar, name_en, latitude, longitude, category,"
" city, address, source, popularity_score, location"
") VALUES " + ",\n".join(values) + ";"
)
con.run(sql)
total_inserted += len(batch)
print(f" -> Progress: {total_inserted:,} / {len(all_new_places):,} places inserted...")
con.run("COMMIT;")
print(f"✅ Ingestion committed successfully in {time.time() - t_insert:.2f}s!")
# 6. Link Administrative Hierarchy (Governorates & Districts)
print("\n🏛️ Step 6: Linking administrative hierarchy (Governorates & Districts)...")
t_admin = time.time()
con.run("""
UPDATE places_iraq p
SET
governorate_id = ab.id,
city = COALESCE(ab.name_ar, ab.name, p.city)
FROM admin_boundaries ab
WHERE ab.country_code = 'IQ'
AND ab.admin_level = 4
AND (p.governorate_id IS NULL OR p.city = 'العراق')
AND ST_Within(p.location::geometry, ab.geom::geometry);
""")
print(f" ✓ Linked governorates in {time.time() - t_admin:.2f}s.")
# 7. Refresh Materialized View
print("\n🔄 Step 7: Refreshing materialized view unified_search_index concurrently...")
t_mv = time.time()
try:
con.run("REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;")
print(f" ✓ unified_search_index refreshed in {time.time() - t_mv:.2f}s!")
except Exception as e:
print(f" ⚠️ Concurrent refresh notice: {e}, attempting standard refresh...")
con.run("REFRESH MATERIALIZED VIEW unified_search_index;")
print(f" ✓ unified_search_index refreshed!")
# 8. Analyze
print("\n⚡ Step 8: Optimizing database query planner with ANALYZE...")
con.run("ANALYZE places_iraq;")
con.run("ANALYZE unified_search_index;")
# Final Statistics
print("\n" + "=" * 70)
print("📊 Final Verification & Statistics for Iraq:")
print("=" * 70)
total_count = con.run("SELECT count(*) FROM places_iraq;")[0][0]
print(f" 🎯 Total Places in places_iraq: {total_count:,}")
by_source = con.run("""
SELECT source, count(*)
FROM places_iraq
GROUP BY source
ORDER BY count(*) DESC;
""")
print(" 📈 Breakdown by Source:")
for src, cnt in by_source:
print(f" - {src}: {cnt:,}")
by_gov = con.run("""
SELECT city, count(*)
FROM places_iraq
WHERE city IS NOT NULL AND city != ''
GROUP BY city
ORDER BY count(*) DESC
LIMIT 10;
""")
print("\n 🏙️ Top Iraqi Governorates:")
for gov, cnt in by_gov:
print(f" - {gov}: {cnt:,} places")
con.close()
print(f"\n🎉 Total script execution time: {time.time() - t0:.2f}s")
print("✨ Iraq geospatial database is now vibrantly enriched!")
if __name__ == '__main__':
main()