# stapel-geo 0.4.0

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 21 · extension points 4 · operations 16 · error codes 50 · flows 5.
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
- apply_formatter — stapel_geo.geocoding.format.apply_formatter
  Stamp properties.formatted on a GeocodeProperties using the configured ADDRESS_FORMATTER, swallowing a formatter's exception into a null label. A custom Geocoder implementation calls this on every feature it maps, so third-party providers carry the same display line as the built-in ones.
- 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.
- format_address — stapel_geo.geocoding.format.format_address
  instead of: each product joining name/street/housenumber/city/country its own way
  Build the one-line human label for a geocoded place from its components, in the country's own postal order (house number before or after the street). Already applied to every feature this library returns as properties.formatted — call it directly only when you hold GeocodeProperties you assembled yourself, and override it fleet-wide via STAPEL_GEO['ADDRESS_FORMATTER'] rather than reformatting per product.
- 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.
- resolve_point — stapel_geo.geocoding.service.resolve_point
  instead of: a composer that just stores raw latitude/longitude and never resolves an address, geocode('reverse') followed by hand-assembled address text and a separate geohash call
  Turn one coordinate pair into a CONFIRMABLE place in a single round trip: the display line, the address components, the geohash to store, the runner-up candidates, and optionally the nearest known Location rows. This is the server half of 'detect my position' and of a dropped map pin. Call this instead of chaining geocode('reverse') + your own label assembly + your own geohash call — that chain is exactly what every product was rebuilding.
- street_line — stapel_geo.geocoding.format.street_line
  Just the street half of an address — street and house number joined in the order that country writes them ('7 Tverskaya Street' vs 'Тверская улица, 7'). Reach for this when you render address components in your own layout and only need the one line the anglocentric default gets backwards.
### factory
- build_map_config — stapel_geo.basemap.build_map_config
  Assemble the location picker's configuration from STAPEL_GEO — tile template, the attribution the ODbL licence obliges the map to display, the zoom envelope, the operating bbox, the search-as-you-type discipline. Reach for this in a server-rendered host instead of reading the MAP_* settings one by one, so the page and the geo-react picker cannot disagree about what the map is allowed to do.
- 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 (16) — call by operationId, never by a typed path
Paths are relative to `/geo/api/v1/`.
### Geocoding
- GET /geocoding/resolve — geo_api_v1_geocoding_resolve_retrieve
- 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
### Map
- GET /map/config — geo_api_v1_map_config_retrieve

## Error codes (50) — 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_bbox [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
- error.503.mandate_unavailable [503] retry

## Documented flows (5) — 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
- geo.pick_location — Pick a location on a map
