# stapel-geo 0.3.6

Geohash proximity search and geocoding, no GDAL/PostGIS/spatial database: a hierarchical location tree (flat lat/lon points with an auto-encoded geohash and a stable cross-service UUID), a proximity search facade (nearby/radius/bbox) behind one swappable backend, and a geocoder proxy (forward/structured/reverse) behind a provider merge-registry, throttled, cached and spend-ledgered per call.

Contract: axes 2 · surface 16 · extension points 4 · operations 14 · error codes 48 · flows 4.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- GEOCODER [enum, default "photon"] — Default geocoding provider
  Default geocoding provider name, resolved through the GEOCODERS merge-registry (built-in photon/nominatim, or a registered name). google/yandex are key-gated stubs a host implements with its own PAYG keys (conf.py, MODULE.md 'Geocoder provider seam').
- SEARCH_BACKEND [enum, default "stapel_geo.search.postgres.PostgresGeoSearchBackend"] — Proximity search backend
  Which proximity-search backend serves nearby/radius/bbox. Default runs geohash prefix expansion over the primary database (no extra infra); a Redis GEOSEARCH side-index backend ships for the hot set; Elasticsearch/Solr are named stubs (README.md 'Proximity search facade', conf.py).

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- bbox — stapel_geo.services.bbox
  instead of: a hand-written lat/lon BETWEEN query with no antimeridian handling
  Locations inside a lat/lon rectangle, antimeridian-aware (min_lon > max_lon wraps) — the in-process equivalent of the geo.bbox comm Function. Call this instead of a hand-written lat/lon BETWEEN query, which silently breaks on a box crossing ±180°.
- distance_km — stapel_geo.geohash.distance_km
  instead of: a hand-rolled haversine formula, geohash prefix-similarity used as a distance proxy
  True great-circle (haversine) distance between two geohashes, decoded to their cell centres and rounded to 2 decimals — correct across the antimeridian and near the poles. Call this instead of writing your own haversine formula or approximating distance from geohash prefix similarity.
- encode — stapel_geo.geohash.encode
  instead of: a second geohash-encoding dependency, hand-rolled base32 geohash arithmetic
  Encode a lat/lon pair to a geohash string of the given precision (pure arithmetic, no DB, no GDAL) — the same helper Location.save() and the geo.geohash_encode comm Function call underneath. Reach for this in-process instead of round-tripping through the comm bus, and instead of pulling in a second geohash library.
- geocode — stapel_geo.geocoding.service.geocode
  instead of: calling a Geocoder provider's search/reverse/structured directly, bypassing the cache and the spend ledger
  The one call path the geocoder proxy views use: provider resolution by name, cache lookup (GEOCODE_CACHE_POLICY), the provider call, and an always-written GeocodeCache spend-ledger row. Call this instead of calling a Geocoder provider directly — a direct call skips the cache and, more importantly, skips the accounting row that makes provider spend visible.
- nearby — stapel_geo.geohash.nearby
  instead of: a hand-rolled geohash neighbour-widening / cell-expansion search
  The proven 9-cell geohash neighbour-widening top-K search — equator/antimeridian/pole safe, with a proven-coverage stopping rule and an authoritative full-scan fallback. This is what PostgresGeoSearchBackend runs underneath; a custom SEARCH_BACKEND implementation should call this instead of re-deriving geohash neighbour widening, which is exactly where the pole/antimeridian edge cases hide.
- nearby_by_coords — stapel_geo.services.nearby_by_coords
  Top-K nearest Location summaries to a coordinate pair (nearest-first), through the configured SEARCH_BACKEND — the in-process equivalent of the geo.nearby comm Function. Call this directly when you are already in the same process instead of round-tripping through comm.
- nearby_by_geohash — stapel_geo.services.nearby_by_geohash
  Top-K nearest Location summaries to a geohash (decoded to its cell centre) — the in-process equivalent of geo.nearby's geohash form. Prefer this over decoding the geohash yourself and calling nearby_by_coords.
- nearby_rows_by_coords — stapel_geo.services.nearby_rows_by_coords
  Same search as nearby_by_coords but returns ORM Location rows (each carrying a .distance_km attribute) instead of summary dicts — reach for this when you need to chain further queryset/model access rather than the cross-service JSON shape.
- nearby_rows_by_geohash — stapel_geo.services.nearby_rows_by_geohash
  Same search as nearby_by_geohash but returns ORM Location rows (each carrying .distance_km) — for callers staying inside the ORM rather than consuming the JSON summary shape.
- radius — stapel_geo.services.radius
  Every Location within radius_km of a point, ascending distance (membership, not top-K) — the in-process equivalent of the geo.radius comm Function. Call this instead of nearby() with a large limit and a manual distance filter, which is not the same query (nearby is top-K, this is membership).
- rank_by_proximity — stapel_geo.geohash.rank_by_proximity
  Sort any iterable of geohash-carrying candidates nearest-first against a target geohash (candidates without a geohash sort last). The building block for a custom STAPEL_GEO['SEARCH_BACKEND']: use it to rank your own candidate set instead of writing another haversine sort.
- resolve — stapel_geo.services.resolve
  Resolve a Location UUID to a summary dict for cross-service reference checks (returns {found: false, ...} on a miss, never raises) — the in-process equivalent of the geo.resolve comm Function. Use this instead of a bare Location.objects.filter(uuid=...).first() when another module just needs to validate/expand a stored location_id.
### factory
- get_backend — stapel_geo.search.__init__.get_backend
  Instantiate the configured STAPEL_GEO['SEARCH_BACKEND'], validated against the GeoSearchBackend protocol (nearby/radius/bbox). Reach for this instead of import_string-ing the setting yourself when you need the raw backend object — e.g. RedisGeoSearchBackend's own index()/rebuild() maintenance methods, which the services.py facade does not expose.
- get_geocoder — stapel_geo.geocoding.service.get_geocoder
  Instantiate the geocoder provider registered under a name (default STAPEL_GEO['GEOCODER']), validated as a Geocoder subclass. Reach for this instead of resolving the GEOCODERS registry and import_string-ing the class yourself when you need a raw provider call outside the cached/ledgered geocode() path.
- register_geocoder — stapel_geo.geocoding.providers.register_geocoder
  Register (or, with None/"", unregister) a geocoder provider by dotted path at runtime, from an AppConfig.ready() — the fork-free way to add a provider without going through the STAPEL_GEO['GEOCODERS'] setting. Runtime registrations win over both the setting and the built-ins.
- registered_geocoders — stapel_geo.geocoding.providers.registered_geocoders
  The effective name -> dotted-path geocoder registry (built-ins merged under the GEOCODERS setting merged under runtime register_geocoder() calls). Reach for this instead of re-deriving the merge yourself — e.g. to list available providers in an admin dropdown or a diagnostics endpoint.

## Extension points — what a product replaces, fork-free
- GEOCODERS [merge_registry]
  MERGE registry ({name: dotted_path}) over BUILTIN_GEOCODERS (photon/nominatim/google/yandex); None/'' removes a builtin name. Also extendable at runtime via register_geocoder() (geocoding/providers.py, MODULE.md 'Geocoder provider seam').
- GEOCODE_CACHE_POLICY [dotted_path]
  REPLACE seam: GeocodeCachePolicy ABC (should_cache/lookup/store); default LedgerCachePolicy answers from the GeocodeCache ledger table within GEOCODE_CACHE_TTL_DAYS (geocoding/cache.py, MODULE.md 'Geocode cache seam').
- SEARCH_BACKEND [dotted_path]
  REPLACE seam: implement the GeoSearchBackend protocol (nearby/radius/bbox) and point the setting at your class (search/base.py, MODULE.md 'Search backend seam').
- serializer_seams [class_override]
  SerializerSeamMixin on geocoder views (response_serializer_class) and LocationViewSet (a plain DRF ModelViewSet, serializer_class/get_serializer_class) — subclass and remount to swap (views.py, geocoding/views.py, MODULE.md 'Serializer seams').

## Fits with — fleet dependencies
- stapel-core (required) — comm bus (Functions geo.nearby/geo.radius/geo.bbox/geo.geohash_encode/geo.resolve), AppSettings config layer, flows, error registry (pyproject.toml dependency; conf.py:25, functions.py:25, flows.py:12, errors.py:6)

## HTTP operations (14) — call by operationId, never by a typed path
Paths are relative to `/geo/api/v1/`.
### Geocoding
- GET /geocoding/reverse — geo_api_v1_geocoding_reverse_retrieve
- GET /geocoding/search — geo_api_v1_geocoding_search_retrieve
- GET /geocoding/structured — geo_api_v1_geocoding_structured_retrieve
### Locations
- GET /locations/by-parent/{parent_id}/ — geo_api_v1_locations_by_parent_list
- GET /locations/countries/ — geo_api_v1_locations_countries_list
- POST /locations/ — geo_api_v1_locations_create
- DELETE /locations/{id}/ — geo_api_v1_locations_destroy
- GET /locations/ — geo_api_v1_locations_list
- GET /locations/nearby-by-coords/ — geo_api_v1_locations_nearby_by_coords_list
- GET /locations/nearby-by-geohash/ — geo_api_v1_locations_nearby_by_geohash_list
- PATCH /locations/{id}/ — geo_api_v1_locations_partial_update
- GET /locations/{id}/ — geo_api_v1_locations_retrieve
- PUT /locations/{id}/ — geo_api_v1_locations_update
- GET /locations/validate-uuid/{uuid}/ — geo_api_v1_locations_validate_uuid_retrieve

## Error codes (48) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.geohash_required [400] fix_input
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_geojson [400] fix_input
- error.400.invalid_import_status [400] fix_input
- error.400.invalid_params [400] fix_input
- error.400.lat_lon_required [400] fix_input
- error.400.uuid_required [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.502.geocoder_unavailable [502] retry

## Documented flows (4) — full steps in docs/flows.json
- geo.geocode_address — Geocode an address
- geo.location_browse — Browse the location tree
- geo.location_nearby — Find locations near a point
- geo.location_resolve — Validate and expand a location reference
