tlf-geo-profiler
Given a coordinate in Nepal, resolve it to an administrative unit (Province -> District -> Local Level -> Ward -> postal code) and attach a demographic profile for that unit. Data is authentic as it is scrapped from CBS official website.
>>> from tlf_geo_profiler import profile
>>> p = profile(27.7040, 85.3070) # Kathmandu Durbar Square
>>> p.admin_unit.district, p.admin_unit.local_level, p.admin_unit.ward
('Kathmandu', 'Kathmandu', 24)
>>> p.demographics.population
4529
Installation
pip install tlf-geo-profiler
Requires Python >=3.10. Pulls in shapely, geopandas, pyarrow, and
tlf-geo. All boundary and lookup data ships inside the package itself, so
there's nothing else to download and no network calls at runtime.
Quickstart
from tlf_geo_profiler import profile
p = profile(27.7040, 85.3070)
p.admin_unit # AdminUnit(province="Bagmati", district="Kathmandu", ...)
p.demographics # DemographicProfile(population=4529, households=1174, ...)
profile(lat, lon) is the only function most callers need. It returns a
RegionalProfile:
@dataclass(frozen=True)
class RegionalProfile:
admin_unit: AdminUnit
demographics: DemographicProfile
Coordinate order
Every public function takes (lat, lon), i.e. (27.7040, 85.3070) for
Kathmandu, matching how coordinates are usually written and how you'd read
them off Google Maps. (Internally the library converts to (lon, lat) for
shapely/GeoJSON, but you never have to think about that.)
Errors
profile() and resolve_admin_unit() raise ValueError if the coordinate
falls outside all four Nepal boundary layers entirely (e.g. the middle of
the ocean, or another country):
>>> profile(0.0, 0.0)
ValueError: (0.0, 0.0) is outside all known Nepal boundaries
>>> profile(28.61, 77.21) # Delhi
ValueError: (28.61, 77.21) is outside all known Nepal boundaries
They do not raise for a valid Nepal point that just lacks fine-grained
data (see "Handling remote/protected-area points" under Examples below) -
you get back an AdminUnit with some fields set to None instead.
API reference
profile(lat: float, lon: float) -> RegionalProfile
The full pipeline: resolves the admin unit, then looks up demographics for it. This is what you want in almost all cases.
Dict / JSON output
RegionalProfile has .to_dict() and .to_json() for when you want
everything as a plain, JSON-serializable structure (an API response, a
file, a message queue payload, etc.) instead of a dataclass:
>>> from tlf_geo_profiler import profile
>>> p = profile(27.7040, 85.3070)
>>> p.to_dict()
{
"admin_unit": {
"province": "Bagmati", "province_code": "NP-P3",
"district": "Kathmandu", "local_level": "Kathmandu",
"local_level_type": "metropolitan", "local_level_osm_id": "relation/...",
"local_level_source": "osm", "local_level_code": "32706",
"ward": 24, "ward_osm_id": "relation/...", "tole": None,
"postal_code": "3060824",
},
"demographics": {
"population": 4529, "households": 1174,
"male_population": 2382, "female_population": 2147, "extra": {},
},
}
>>> p.to_json(indent=2) # any kwargs are passed through to json.dumps
'{\n "admin_unit": {\n "province": "Bagmati",\n ...\n}'
If you don't need the RegionalProfile object at all, there are two
one-call convenience wrappers that go straight from coordinate to plain
output:
from tlf_geo_profiler import profile_dict, profile_json
profile_dict(27.7040, 85.3070) # -> dict, same shape as .to_dict()
profile_json(27.7040, 85.3070, indent=2) # -> str, same as .to_json(indent=2)
The dict is nested ({"admin_unit": {...}, "demographics": {...}}) rather
than flattened into one level, so field names never collide between the
two sub-objects. Every field is already a plain str, int, dict or
None, so no custom JSON encoder is needed.
resolve_admin_unit(lat: float, lon: float) -> AdminUnit
Just the geographic part, if you don't need demographics. Returns:
@dataclass(frozen=True)
class AdminUnit:
province: str | None # e.g. "Bagmati"
province_code: str | None # ISO 3166-2, e.g. "NP-P3"
district: str | None # e.g. "Kathmandu"
local_level: str | None # e.g. "Kathmandu"
local_level_type: str | None # metropolitan | sub_metropolitan
# | municipality | rural_municipality
local_level_osm_id: str | None # e.g. "relation/1234567"
local_level_source: str | None # "osm" | "derived_from_wards"
local_level_code: str | None # crosswalked code used to join
# postal codes & demographics
ward: int | None # corrected ward number, e.g. 24
ward_osm_id: str | None
tole: str | None # currently always None - unimplemented,
# see "Known limitations" below
postal_code: str | None # e.g. "3060824"
Example, printed in full:
>>> from tlf_geo_profiler.boundaries import resolve_admin_unit
>>> resolve_admin_unit(27.7040, 85.3070)
AdminUnit(
province='Bagmati',
province_code='NP-P3',
district='Kathmandu',
local_level='Kathmandu',
local_level_type='metropolitan',
local_level_osm_id='relation/...',
local_level_source='osm',
local_level_code='32706',
ward=24,
ward_osm_id='relation/...',
tole=None,
postal_code='3060824',
)
resolve_ward(lat: float, lon: float) -> dict | None
Lower-level lookup against just the ward layer, for when you only care
whether a point falls in a ward polygon and what OSM calls it. Returns
None (not an error) if the point isn't inside any ward polygon.
>>> from tlf_geo_profiler.boundaries import resolve_ward
>>> resolve_ward(27.7040, 85.3070)
{'osm_id': 'relation/...', 'name': 'Kathmandu-24', 'ward': '24', 'ward_no': 24}
ward is the raw OSM tag (string, occasionally wrong - see below);
ward_no is the corrected integer and is what you should use.
get_demographics(local_level_code, ward_no=None) -> DemographicProfile
Usually called for you inside profile(), but usable standalone if you
already have a local_level_code from somewhere else (e.g. cached from a
previous resolve_admin_unit() call, so you can skip the spatial lookup):
@dataclass(frozen=True)
class DemographicProfile:
population: int | None
households: int | None
male_population: int | None
female_population: int | None
extra: dict
>>> from tlf_geo_profiler.demographics import get_demographics
>>> get_demographics("32706", ward_no=24) # ward-level
DemographicProfile(population=4529, households=1174, male_population=2382, female_population=2147, extra={})
>>> get_demographics("32706") # no ward -> whole municipality
DemographicProfile(population=862400, households=238966, male_population=438256, female_population=424144, extra={})
If ward_no doesn't have ward-level data, or is omitted, you get the
figures summed across the whole local level. If local_level_code is
None or unrecognized, you get an empty DemographicProfile() (all
fields None) rather than an error - always check population is not None
before using the numbers.
More examples
Building a one-line summary for a point:
from tlf_geo_profiler import profile
def describe(lat, lon):
p = profile(lat, lon)
u = p.admin_unit
where = " > ".join(filter(None, [u.province, u.district, u.local_level,
f"Ward {u.ward}" if u.ward else None]))
pop = f", pop. {p.demographics.population:,}" if p.demographics.population else ""
return f"{where}{pop}"
>>> describe(29.9707, 81.8203) # Simkot, Humla
'Karnali > Humla > Simkot > Ward 5, pop. 2,145'
Handling remote/protected-area points that only resolve partially:
p = profile(27.5300, 84.3500) # inside Chitwan, but no ward polygon here
p.admin_unit.district # 'Chitwan'
p.admin_unit.ward # None
p.admin_unit.local_level # None
p.demographics.population # None - no local_level_code to join demographics on
Batch-processing a list of coordinates, tolerating out-of-Nepal points:
from tlf_geo_profiler import profile
points = [(27.7040, 85.3070), (28.2096, 83.9560), (0.0, 0.0)]
results = []
for lat, lon in points:
try:
results.append(profile(lat, lon))
except ValueError:
results.append(None) # outside Nepal
Known limitations of the data (OpenStreetMap)
- 4 local levels are derived, not taken from OSM: Butwal, Nepalgunj,
Duduwa and Sainamaina were missing from the level-7 export. Their shapes
are built by merging their wards and marked
local_level_source="derived_from_wards", with no local-level type. A later query found real OSM outlines for Butwal and Sainamaina, identical to the derived shapes. None was found for Nepalgunj or Duduwa. - 5 wards are missing from OSM: Nepalgunj-15, Pyuthan-2, Sarumarani-1,
Nisikhola-7 and Nisikhola-8. A point there resolves to its local level
with
ward=None. - About 8% of Nepal's land has no ward polygon (mostly protected areas). Such points resolve to province and district only, and sometimes local level.
- 13 wards have wrong ward numbers in OSM (typos, superscript digits,
duplicates). They are corrected in
data/ward_number_fixes.csv. - No official codes: OSM local levels carry no CBS/LGD code, and names
repeat across districts, so census data can't be joined by name alone;
local_level_codecomes fromtlf_geo.GeoResolvermatching name + district, and can occasionally fail to resolve (see below). AdminUnit.toleis unused - the field exists but nothing inboundaries.pyever populates it. Either wire it up or drop the field before relying on it.- Silent resolver misses: if
tlf_geo.GeoResolver.resolve()can't match a local level's name,local_level_code(and therefore postal code and demographics) silently come backNonerather than raising - there's no way from the return value alone to tell "no data for this area" apart from "the name matcher failed."
Status
profile(lat, lon) is fully wired end to end: boundary resolution (all four
levels), postal code, and census demographics all run and are covered by
tests/test_profiler.py.
License
Development
This section is for people modifying the package itself, not for people
using it. This package lives inside the
TLF-PROFILERS monorepo,
at packages/tlf-geo-profiler:
git clone https://github.com/PujanPandey07/TLF-PROFILERS.git
cd TLF-PROFILERS/packages/tlf-geo-profiler
Project layout
boundaries.py- coordinate -> admin unit (point-in-polygon, 4 layers) and postal code lookup (ward- and local-level granularity, joined bylocal_level_code+ ward number, not by district)scripts/build_admin_layer.py- Overpass GeoJSON -> bundled GeoParquet. Rebuild the boundary layers with this if the source OSM data changes.scripts/inspect_data.py- dump the bundled parquet/csv files to the console (and optionally todata_preview/*.csv) for manual inspectiondemographics.py- admin unit -> demographic stats from the 2021 census (data/demographics_ward.csv), joined bylocal_level_codeand wardprofiler.py- publicprofile(lat, lon)entry point
Running the tests
If you're working on this package standalone:
pip install -e ".[dev]"
pytest -v
If you're working inside the TLF-PROFILERS monorepo (uv workspace), run
from the workspace root instead, so the shared uv.lock is used:
uv sync
uv run --package tlf-geo-profiler pytest packages/tlf-geo-profiler/tests -v
or equivalently, from inside packages/tlf-geo-profiler/: uv run pytest -v.
tests/test_boundaries.py checks the raw boundary-resolution layer
(including data invariants like layer counts and geometry validity);
tests/test_profiler.py checks the full profile() pipeline end to end
against known coordinates, including the .to_dict()/.to_json() output.
Smoke-testing with sample data
scripts/smoke_test.py exercises every public function
(profile, resolve_admin_unit, resolve_ward, get_demographics,
profile_dict, profile_json) against a handful of known coordinates and
prints the results, for a quick manual sanity check beyond what pytest
asserts:
uv run --package tlf-geo-profiler python packages/tlf-geo-profiler/scripts/smoke_test.py
# or, standalone: python scripts/smoke_test.py
Release files for tlf-geo-profiler 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tlf_geo_profiler-0.1.0.tar.gz | 26.8 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tlf_geo_profiler-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.7 MB
Release files / tlf_geo_profiler-0.1.0.tar.gz
| Download URL | tlf_geo_profiler-0.1.0.tar.gz |
|---|---|
| Size | 26.8 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
452b41d55de05c04ec174f10dcc0777fa38558c9a2e1458b3b4fd2d82c6c85e0
|
|
BLAKE2b-256 checksum How to use checksums |
40ecb63e2094821ef2c387f060619b96daed80e03e0fd27add6e5951332bb89e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / tlf_geo_profiler-0.1.0-py3-none-any.whl
| Download URL | tlf_geo_profiler-0.1.0-py3-none-any.whl |
|---|---|
| Size | 26.9 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
74fe3471f824b319f699d357102b7b7a30b86062f22b4c5d6b2d19a01811fa56
|
|
BLAKE2b-256 checksum How to use checksums |
0d03c833e2e8d238aad4346b03f393bb80ba4155ef6394b5d4473754b64bd853
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|