tlf-geo
One official code, however many ways someone spelled the place.
tlf-geo resolves messy real-world Nepali place names — provinces,
districts, and all 753 local government units (gaunpalika, municipality,
sub-metropolitan city, metropolitan city) — to their official NSO codes,
handling spelling variants, script mixing (Roman/Devanagari), common
administrative-suffix noise, and the ~30 place names that legitimately exist
in more than one district.
Part of TLF (The Living Fact), an initiative of Corpola Tech and the Open Tech Community for making Nepal's civic and census data interoperable. Full project story, data sources, and architecture reasoning: github.com/PujanPandey07/TLF-The-Living-Fact-
Status: Alpha (v0.1.0). Built on real crosswalk data from Nepal's local-level code registry; see Contributing for how to report a missing or incorrect place.
Installation
pip install tlf-geo
Quick start
from tlf_geo import GeoResolver
resolver = GeoResolver()
# Resolve a place name, disambiguated by district
result = resolver.resolve("Kalika", district="Rasuwa")
# {
# "code": "32902", "canonical_key": "kalika", "level": "gaunpalika",
# "name": "Kalika", "name_ne": "कालिका गाउँपालिका",
# "district": "rasuwa", "district_code": 29,
# "province_code": 3, "wards": "5",
# }
# Same name without a district — genuinely ambiguous, raises instead of guessing
resolver.resolve("Kalika")
# -> AmbiguityError: 3 candidates (Rasuwa/gaunpalika, Kalikot/gaunpalika, Chitawan/municipality)
Resolving a whole DataFrame column at once, without letting one bad row kill the batch:
import pandas as pd
df = pd.read_csv("survey.csv")
resolved = resolver.resolve_df(
df,
name_col="place_name",
district_col="district_name",
)
# adds `geo_status` (resolved / ambiguous / not_found / invalid_district /
# level_mismatch) and `geo_error_reason` columns, plus resolved code/name/
# level/etc. columns for rows that succeeded
Why resolution can fail — and why that's on purpose
resolve() never guesses. If a name doesn't map cleanly to exactly one
place, it raises one of three exceptions instead of silently picking a
"probably right" answer:
| Exception | Raised when | Carries |
|---|---|---|
NotFoundError |
No candidate matches at all, or a given district/district_code rules out every candidate for that name |
A message describing what was searched |
AmbiguityError |
More than one real place matches (same name, multiple districts, no district given to disambiguate) | Full candidate dicts, one per real place |
FuzzyMatchError |
Nothing matched exactly or after suffix-stripping, so the name fell through to fuzzy matching | Top 3 (matched_key, score) suggestions, no minimum-similarity cutoff — you decide what counts as close enough |
This mirrors tlf-core's "never guess, never drop" rule: unresolved data
surfaces with enough information to resolve it by hand, rather than being
silently miscategorized.
API Reference
Everything below is available from from tlf_geo import GeoResolver (the
exceptions are importable from tlf_geo.exceptions or the top-level
package).
GeoResolver()
Loads the bundled places.yaml (alias/fuzzy data) and codes.yaml
(official codes + district disambiguation) — no arguments, no external
files needed.
resolver.resolve(name, district=None, district_code=None, level=None) -> dict
Resolves one place name to a single official record.
district(name string) ordistrict_codenarrows candidates to that district; if both are given,district_codetakes priority.level("province","district","gaunpalika","municipality","sub_metropolitan_city","metropolitan_city") narrows candidates to that administrative level.- Returns a dict:
code, canonical_key, level, name, name_ne, district, district_code, province_code, wards. - Raises
NotFoundError,AmbiguityError, orFuzzyMatchError— see above — if it can't resolve to exactly one place.
resolver.resolve("Kalika", district="Rasuwa", level="gaunpalika")
resolver.resolve("Kalka") # typo -> FuzzyMatchError, suggests kalika/kakani/malika
Note: protected areas (national parks, wildlife reserves) are not resolvable by name through
resolve()— that's a deliberate scope decision, not a bug. Useprotected_areas()to list them instead.
resolver.search(name, limit=5) -> list[tuple[str, float]]
The non-raising sibling of resolve(), meant for interactive use —
autocomplete, a "did you mean...?" prompt, a search box — where you want to
see options, not get exactly one answer or an exception.
Runs the same 3-tier lookup as resolve(), but always returns a list of
(label, score) pairs instead of raising:
- An exact/suffix-stripped match returns
"canonical_key (level)"labels at a score of100.0— genuinely distinct places sharing a bare name (e.g. Kalika gaunpalika vs. Kalika municipality) show up as separate entries rather than being collapsed into one. - A fuzzy match returns the same
(matched_key, score)suggestionsFuzzyMatchErrorwould have carried, just returned instead of raised. - No match at all returns
[].
search() never filters by district or level the way resolve() does —
it's purely "what places even loosely match this string."
resolver.search("Kalika")
# [("kalika (gaunpalika)", 100.0), ("kalika (municipality)", 100.0)]
resolver.search("Kalka") # typo
# [("kalika", 91.0), ("kakani", 73.0), ("malika", 73.0)]
resolver.search("zzzznotaplace")
# []
resolver.resolve_df(df, name_col, name_ne_col=None, district_col=None, district_code_col=None, level_col=None) -> DataFrame
Batch version of resolve() for a whole DataFrame. Never raises — every
row's outcome is captured instead of stopping the batch:
geo_status |
Meaning |
|---|---|
resolved |
Matched exactly one place; resolved fields filled in |
ambiguous |
Multiple real candidates, no district given to disambiguate |
not_found |
No candidate matched at all |
invalid_district |
Name matched, but not in the given district |
level_mismatch |
Name matched, but not at the given level |
Rows that don't resolve get their resolved-field columns left blank and a
human-readable geo_error_reason instead of raising.
Listing & filtering — return pandas DataFrames by default
Every listing method takes an as_dict=False param — pass as_dict=True
to get a plain list[dict] back instead of a DataFrame, for callers who
aren't otherwise using pandas (a script, a JSON API response).
| Function | Returns |
|---|---|
resolver.provinces(as_dict=False) |
All 7 provinces |
resolver.districts(province=None, province_code=None, as_dict=False) |
All districts, optionally filtered by province |
resolver.local_levels(level=None, district=None, district_code=None, province_code=None, as_dict=False) |
Local government units, filterable by level, district, and/or province |
resolver.protected_areas(as_dict=False) |
National parks, wildlife reserves, etc. — the listing method, not name-resolvable via resolve() |
resolver.provinces(as_dict=True)
# [{"code": 1, "canonical_key": "koshi", "name": "Koshi", "name_ne": "कोशी"}, ...]
Code lookups — null-safe, .apply()-safe, never raise
| Function | Does |
|---|---|
resolver.get_by_code(code) |
Full record for an official code |
resolver.get_name(code) |
Canonical English name for a code |
resolver.get_canonical(code) |
Canonical key for a code |
resolver.get_wards(code) |
Ward count for a local level |
resolver.get_parent(code) |
Parent district/province code |
Each is safe to call directly inside df["code"].apply(resolver.get_name) —
unknown codes return None rather than raising.
Validation
resolver.is_valid_code(code) -> boolresolver.is_valid_name(name) -> bool
Django integration
resolver.to_choices(level, province_code=None, district_code=None) -> list[tuple[str, str]]
Returns (code, name) pairs ready for a Django ChoiceField/model field's
choices=, optionally scoped to a province or district.
# models.py
district_code = models.CharField(
max_length=5,
choices=resolver.to_choices("municipality", province_code=3),
)
What this does NOT do
- No name-based resolution of protected areas (listing via
protected_areas()works fine —resolve()by name doesn't) - No fuzzy match cutoff —
FuzzyMatchError's top-3 suggestions are always returned, however weak, so you judge what's close enough - No BS↔AD calendar handling or general value normalization — that's
tlf-core's job, not this package's
Contributing
places.yaml and codes.yaml are built from Nepal's official local-level
code crosswalk. If you find a place that doesn't resolve, resolves
incorrectly, or is missing an alias:
- Open an issue with the raw name, the district it belongs to, and what you expected — or
- Clone this repo, add the alias/entry directly to
places.yaml(andcodes.yamlif it's a new place rather than a spelling variant), and open a PR.
For the project's background, data sources, and architecture decisions, see the main repo.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tlf_geo-0.1.0.tar.gz.
File metadata
- Download URL: tlf_geo-0.1.0.tar.gz
- Upload date:
- Size: 80.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2871a7dead9f5efab5ad08d599e50a0845e9508d7603c61a51144fb676a2f78a
|
|
| MD5 |
0ef3a3b9c1095108612b3dfd4a9b62fd
|
|
| BLAKE2b-256 |
1d47dacffe135d81be6bb9fa7795c11e73cb0ee09de61be5d5e3c63159e4cd1d
|
File details
Details for the file tlf_geo-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tlf_geo-0.1.0-py3-none-any.whl
- Upload date:
- Size: 72.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0cb1dcda842b5e79a5a363f48c5d3389c82de414be84acaca31df0eaaebb058f
|
|
| MD5 |
6635b193024661d61ea099698acb6e23
|
|
| BLAKE2b-256 |
03aa1f51f3025a635063fc6d5773be1b5475915958c900bde93c37a8035d71da
|