navi/backend/services/navi_landclass/db.py
malice d08834451f Add navi-landclass service (extraction #4) (#4)
New services/navi_landclass/ on :8424 — single blueprint, behavior-identical
port of recon's lib/landclass.py + the /api/landclass handler.

  GET /api/landclass?lat=&lon=  -> { lat, lon, classifications[], count,
    is_public, is_private, summary }; 400 on bad/out-of-range lat/lon.

db.py: faithful port of recon's PostGIS module — lazy module-level
psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=3) from PADUS_DB_* env;
the ST_Intersects query on pad_units (antimeridian filter, acres-ordered,
limit 10); all PAD-US code->label maps verbatim; graceful degradation
(returns [] when PG is unreachable, never raises/500). Adds reset_pool()
(create_app resets per worker) and probe_db() (SELECT 1) for admin health.

No filesystem state — PostGIS is external. No DB-on-disk migration; only the
5 PADUS_DB_* env vars (PADUS_DB_PASSWORD is a real secret, masked in
admin-info via mask_key; the other 4 shown plain). adds psycopg2-binary>=2.9.

Decision — DROPPED the recon `has_landclass` profile-flag gate: the frontend
already gates on its own has_landclass feature flag, and removing the
cross-service config dependency keeps navi-landclass self-contained per the
"only API" rule (the service's existence is the feature being available).

navi-geo coupling (reverse-bundle needs landclass) — per Phase A, recommend
Option B: navi-geo HTTP-calls /api/landclass and reads `.summary` (the
endpoint already returns it); no shared module. Decided when #6 lands.

Tests (8; recon had 2): point-with-coverage -> classification + decoded
labels, ocean point -> empty, bad/missing/out-of-range lat/lon -> 400, PG
down -> graceful 200 empty (not 500), format_summary unit. Full suite 46.

Deploy: systemd unit (:8424) + nginx snippet (one ^~ /api/landclass block, no
proxy_cache; /api/landclass is public so no Caddy edit — TIER 2 already
routes through nginx since extraction #2).

See ../recon_refactor/extraction-4-phase-a.md (which also corrects the handoff:
/mnt/nav/padus/ is source GIS files, NOT a runtime path — this service has no
/mnt/nav dependency, only PADUS_DB_* + PG network access).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:08:58 -06:00

294 lines
9.7 KiB
Python

"""PAD-US land classification lookup — behavior-identical port of recon's
``lib/landclass.py``.
Point-in-polygon queries against the USGS Protected Areas Database (PAD-US)
in a PostGIS database. Lazy module-level connection pool. If PostgreSQL is
unreachable, functions return empty results gracefully (the feature degrades,
it does not crash). No filesystem state — PostGIS is external.
Env: PADUS_DB_HOST, PADUS_DB_PORT, PADUS_DB_NAME, PADUS_DB_USER,
PADUS_DB_PASSWORD (password is a real secret — never logged/printed).
"""
import logging
import os
import psycopg2
import psycopg2.pool
logger = logging.getLogger('navi_landclass.db')
_pool = None
_pool_failed = False
# ── Label mappings from PAD-US domain tables (ogr2ogr lowercases columns) ──
AGENCY_NAME_MAP = {
'TVA': 'Tennessee Valley Authority',
'BLM': 'Bureau of Land Management',
'BOEM': 'Bureau of Ocean Energy Management',
'USBR': 'Bureau of Reclamation',
'FWS': 'U.S. Fish and Wildlife Service',
'USFS': 'Forest Service',
'DOD': 'Department of Defense',
'USACE': 'Army Corps of Engineers',
'DOE': 'Department of Energy',
'NPS': 'National Park Service',
'NRCS': 'Natural Resources Conservation Service',
'ARS': 'Agricultural Research Service',
'BIA': 'Bureau of Indian Affairs',
'NOAA': 'National Oceanic and Atmospheric Administration',
'BPA': 'Bonneville Power Administration',
'OTHF': 'Other or Unknown Federal Land',
'TRIB': 'American Indian Lands',
'SPR': 'State Park and Recreation',
'SDC': 'State Department of Conservation',
'SLB': 'State Land Board',
}
AGENCY_TYPE_MAP = {
'FED': 'Federal',
'TRIB': 'American Indian Lands',
'STAT': 'State',
'DIST': 'Regional Agency Special District',
'LOC': 'Local Government',
'NGO': 'Non-Governmental Organization',
'PVT': 'Private',
'JNT': 'Joint',
'UNK': 'Unknown',
'TERR': 'Territorial',
'DESG': 'Designation',
}
DESIGNATION_TYPE_MAP = {
'NP': 'National Park',
'NM': 'National Monument',
'NCA': 'Conservation Area',
'NF': 'National Forest',
'NG': 'National Grassland',
'PUB': 'National Public Lands',
'NT': 'National Scenic or Historic Trail',
'NWR': 'National Wildlife Refuge',
'WA': 'Wilderness Area',
'WSR': 'Wild and Scenic River',
'WSA': 'Wilderness Study Area',
'MPA': 'Marine Protected Area',
'NRA': 'National Recreation Area',
'NSBV': 'National Scenic, Botanical or Volcanic Area',
'NLS': 'National Lakeshore or Seashore',
'IRA': 'Inventoried Roadless Area',
'ACEC': 'Area of Critical Environmental Concern',
'RNA': 'Research Natural Area',
'REC': 'Recreation Management Area',
'RMA': 'Resource Management Area',
'WPA': 'Watershed Protection Area',
'REA': 'Research or Educational Area',
'HCA': 'Historic or Cultural Area',
'MIT': 'Mitigation Land or Bank',
'MIL': 'Military Land',
'ACC': 'Access Area',
'SDA': 'Special Designation Area',
'PROC': 'Approved or Proclamation Boundary',
'FOTH': 'Federal Other or Unknown',
'ND': 'Not Designated',
}
PUBLIC_ACCESS_MAP = {
'OA': 'Open Access',
'RA': 'Restricted Access',
'XA': 'Closed',
'UK': 'Unknown',
}
GAP_STATUS_MAP = {
'1': 'Managed for biodiversity (disturbance events proceed)',
'2': 'Managed for biodiversity (disturbance suppressed)',
'3': 'Multiple uses (extractive/OHV)',
'4': 'No known mandate for biodiversity protection',
}
CATEGORY_MAP = {
'Fee': 'Fee',
'Easement': 'Easement',
'Other': 'Other',
'Unknown': 'Unknown',
'Designation': 'Designation',
'Marine': 'Marine Area',
'Proclamation': 'Approved, Proclamation or Extent Boundary',
}
STATE_MAP = {
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
'DC': 'District of Columbia', 'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii',
'ID': 'Idaho', 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa',
'KS': 'Kansas', 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine',
'MD': 'Maryland', 'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota',
'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska',
'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey', 'NM': 'New Mexico',
'NY': 'New York', 'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio',
'OK': 'Oklahoma', 'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island',
'SC': 'South Carolina', 'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas',
'UT': 'Utah', 'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington',
'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
}
def _decode(code, label_map):
"""Decode a PAD-US code using a label map. Returns decoded label or the raw code."""
if not code:
return ''
code = str(code).strip()
return label_map.get(code, code)
def _get_pool():
"""Lazy-init the connection pool. Returns None if Postgres is unreachable."""
global _pool, _pool_failed
if _pool is not None:
return _pool
if _pool_failed:
return None
try:
_pool = psycopg2.pool.SimpleConnectionPool(
minconn=1,
maxconn=3,
host=os.environ.get('PADUS_DB_HOST', 'localhost'),
port=int(os.environ.get('PADUS_DB_PORT', '5432')),
dbname=os.environ.get('PADUS_DB_NAME', 'padus'),
user=os.environ.get('PADUS_DB_USER', 'overture'),
password=os.environ.get('PADUS_DB_PASSWORD', ''),
connect_timeout=5,
)
logger.info("PAD-US PostgreSQL connection pool initialized")
return _pool
except Exception as e:
_pool_failed = True
logger.warning(f"PAD-US PostgreSQL unavailable, land classification disabled: {e}")
return None
def reset_pool():
"""Drop the cached pool/failure flag so the next call re-reads PADUS_DB_* env.
Called by create_app() (and tests) so each worker picks up fresh creds."""
global _pool, _pool_failed
if _pool is not None:
try:
_pool.closeall()
except Exception:
pass
_pool = None
_pool_failed = False
def _query_all(sql, params):
"""Execute a query and return all rows as a list of dicts, or empty list."""
pool = _get_pool()
if pool is None:
return []
conn = None
try:
conn = pool.getconn()
with conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
if not rows:
return []
cols = [desc[0] for desc in cur.description]
return [dict(zip(cols, row)) for row in rows]
except Exception as e:
logger.warning(f"PAD-US query error: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
return []
finally:
if conn:
try:
pool.putconn(conn)
except Exception:
pass
def lookup_landclass(lat, lon):
"""
Look up PAD-US land classifications for a point.
Returns a list of classification dicts, ordered by area ascending
(smallest/most specific first). Empty list on error or no results.
"""
rows = _query_all(
"""SELECT unit_nm, mang_name, mang_type, own_name, own_type,
des_tp, gap_sts, pub_access, category, gis_acres, state_nm
FROM pad_units
WHERE ST_Intersects(geom, ST_SetSRID(ST_MakePoint(%s, %s), 4326))
-- exclude antimeridian-wrapping polygons: 47 BOEM marine artifacts
-- span ~360 deg longitude and false-match non-US points at their lat band
AND (ST_XMax(geom) - ST_XMin(geom)) < 60
ORDER BY gis_acres ASC
LIMIT 10""",
(lon, lat)
)
results = []
for row in rows:
pa_code = str(row.get('pub_access', '')).strip()
results.append({
'unit_name': (row.get('unit_nm') or '').strip(),
'manager_name': _decode(row.get('mang_name'), AGENCY_NAME_MAP),
'manager_type': _decode(row.get('mang_type'), AGENCY_TYPE_MAP),
'owner_type': _decode(row.get('own_type'), AGENCY_TYPE_MAP),
'designation_type': _decode(row.get('des_tp'), DESIGNATION_TYPE_MAP),
'gap_status': str(row.get('gap_sts', '')).strip(),
'public_access': _decode(pa_code, PUBLIC_ACCESS_MAP),
'public_access_code': pa_code,
'category': _decode(row.get('category'), CATEGORY_MAP),
'acres': row.get('gis_acres'),
'state': _decode(row.get('state_nm'), STATE_MAP),
})
return results
def format_summary(classifications):
"""
Format a human-readable summary from classification results.
Returns the most specific unit name, or None if no results.
"""
if not classifications:
return None
# First result is smallest/most specific (ordered by acres ASC)
return classifications[0].get('unit_name') or None
def probe_db():
"""Quick connection health probe for the admin-info endpoint.
Returns (ok: bool, detail: str). Never raises."""
pool = _get_pool()
if pool is None:
return False, 'pool unavailable'
conn = None
try:
conn = pool.getconn()
with conn.cursor() as cur:
cur.execute('SELECT 1')
cur.fetchone()
return True, 'ok'
except Exception as e:
if conn:
try:
conn.rollback()
except Exception:
pass
return False, type(e).__name__
finally:
if conn:
try:
pool.putconn(conn)
except Exception:
pass