Metadata-Version: 2.4
Name: openwalkscore
Version: 0.1.1
Summary: Open, transparent, API-key-free walkability scores (0-100) for any point or area, computed from OpenStreetMap.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/openwalkscore_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/openwalkscore_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/openwalkscore_sdk/issues
Project-URL: Documentation, https://github.com/YusufEminoglu/openwalkscore_sdk#readme
Project-URL: Changelog, https://github.com/YusufEminoglu/openwalkscore_sdk/blob/main/CHANGELOG.md
Keywords: walkability,walk-score,accessibility,15-minute-city,openstreetmap,overpass,urban-analytics,gis,proptech,real-estate,pedestrian,network-analysis,dijkstra,amenities
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Sociology
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: Environment :: Console
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: certifi>=2023.7.22
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
Provides-Extra: networkx
Requires-Dist: networkx>=2.8; extra == "networkx"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: networkx>=2.8; extra == "dev"
Dynamic: license-file

# openwalkscore

**Open, transparent, API-key-free walkability scores (0-100) for any point or area on Earth,
computed from OpenStreetMap.**

[![CI](https://github.com/YusufEminoglu/openwalkscore_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/openwalkscore_sdk/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/openwalkscore.svg)](https://pypi.org/project/openwalkscore/)
[![Python](https://img.shields.io/pypi/pyversions/openwalkscore.svg)](https://pypi.org/project/openwalkscore/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

`openwalkscore` measures how many everyday destinations (groceries, cafes, schools, parks,
clinics, transit stops and more) you can reach on foot, and how walkable the street network
around you is. Every number in the method is published below and lives in an editable
profile, so a score can always be reproduced, audited and adapted.

It is written for urban data scientists, real-estate and proptech developers, planners and
students who need a walkability indicator they can explain, and that works for any city.

> **Disclaimer.** openwalkscore is an independent open-source project. It is **not
> affiliated with, endorsed by or connected to Walk Score® or Redfin**. Walk Score® is a
> registered trademark of its owner. openwalkscore uses its own method and its own data
> (OpenStreetMap); **its scores are not comparable to Walk Score® values** and must not be
> presented as such.

---

## Contents

- [Install](#install)
- [Quick start](#quick-start)
- [Methodology](#methodology)
- [Profiles](#profiles)
- [Python API](#python-api)
- [Command line](#command-line)
- [Data sources and offline use](#data-sources-and-offline-use)
- [HTML maps](#html-maps)
- [Limitations](#limitations)
- [Citing](#citing)
- [License and attribution](#license-and-attribution)

## Install

```bash
pip install openwalkscore
# optional: run shortest paths with networkx instead of the built-in Dijkstra
pip install "openwalkscore[networkx]"
```

Python 3.9 or newer. The only runtime dependency is `certifi` (plus `tomli` on Python < 3.11).
No API key, no account, no `osmnx`/`geopandas` stack required.

## Quick start

```python
from openwalkscore import score_point

r = score_point(48.8530, 2.3499)          # Ile de la Cite, Paris
print(r.score, r.rating)                  # e.g. 100 Excellent
print(r.subscores)                        # {'grocery': 100.0, 'restaurants_cafes': 100.0, ...}
print(r.explain())                        # plain-text, line-by-line justification

for hit in r.nearest_amenities(limit=5):
    print(f"{hit.distance_m:5.0f} m  {hit.category:<18} {hit.name}")
```

The first call downloads amenities and the walking network around the point from the
public Overpass API (this can take up to a minute in a dense city centre) and caches the
response on disk; repeated calls are instant.

```bash
openwalkscore score --lat 48.8530 --lon 2.3499
openwalkscore explain --lat 48.8530 --lon 2.3499
openwalkscore grid --bbox 2.340,48.850,2.360,48.860 --cell 200 -o paris.geojson --html paris.html
```

## Methodology

For a point *p* and a profile with categories *c* (weight *W_c*):

### 1. Amenity categories

Each OpenStreetMap feature with a location (node, or way/relation centroid) is assigned to the
**first** category whose tag rules match (`shop=*` comes after the grocery and healthcare
rules, so a supermarket counts as grocery, not shopping). Default profile:

| Category | Weight | Counted (n) | Ratio (r) | Tags (abridged) |
|---|---:|---:|---:|---|
| Grocery | 3 | 2 | 0.4 | `shop=supermarket/convenience/greengrocer/bakery/butcher/deli`, `amenity=marketplace` |
| Restaurants & cafes | 3 | 10 | 0.8 | `amenity=restaurant/cafe/fast_food/pub/bar/food_court/ice_cream` |
| Healthcare | 1 | 2 | 0.5 | `amenity=pharmacy/clinic/doctors/hospital/dentist`, `shop=chemist`, `healthcare=*` |
| Shopping | 2 | 5 | 0.7 | `shop=*` (except vacant, car, car_parts, fuel) |
| Schools | 1 | 1 | - | `amenity=school/kindergarten/childcare/college/university` |
| Parks & play | 1 | 2 | 0.5 | `leisure=park/playground/garden/recreation_ground/nature_reserve` (not `access=private`) |
| Banks & post | 1 | 1 | - | `amenity=bank/atm/bureau_de_change/post_office` |
| Culture & community | 1 | 2 | 0.5 | `amenity=library/theatre/cinema/arts_centre/community_centre`, `tourism=museum/gallery` |
| Transit stops | 2 | 3 | 0.5 | `highway=bus_stop`, `public_transport=platform`, `railway=station/halt/tram_stop/subway_entrance`, `amenity=ferry_terminal` |

Run `openwalkscore explain` (no coordinates) to print the complete profile as JSON.

### 2. Walking distance

* The walking network is built from OSM ways with a walkable `highway=*` value (footway,
  path, pedestrian, steps, living_street, residential, service, tertiary ... primary, track,
  cycleway, ...). Motorways and trunk roads are excluded unless `foot=yes`; `foot=no`,
  `access=private` and `foot=use_sidepath` are respected.
* Every pair of consecutive way nodes is an undirected edge weighted by its great-circle
  length.
* The scored point and every amenity are **snapped to the nearest edge** (not node), and the
  perpendicular access legs are added to the route.
* Shortest paths come from a pure-Python multi-source **Dijkstra** that starts part-way along
  the origin edge and stops at the decay cut-off (1.6 km). `engine="networkx"` gives
  identical results with networkx.
* Fallback: amenities more than `max_snap_m` (250 m) from the network, or every amenity when
  the point itself has no network nearby, use **straight-line distance x detour factor 1.3**.
  The result reports `distance_mode` (`network`, `straight-line` or `mixed`) per amenity
  and overall.
* An amenity that is close but not connected to the point's network (for example across a
  river with no bridge in the data) gets no credit.

### 3. Distance decay

Credit for an amenity at walking distance *d*:

```
f(d) = 1                                   if d <= 400 m
f(d) = 0.5 * (1 + cos(pi * t))             if 400 m < d < 1600 m,  t = (d - 400) / 1200
f(d) = 0                                   if d >= 1600 m
```

The cosine taper is smooth at both ends: an amenity 700 m away earns 0.85, 1 km away 0.50,
1.3 km away 0.15. A linear ramp is available (`decay.shape = "linear"`).

### 4. Diminishing returns

Within a category the amenities are ranked by credit and the best *n* are kept. The *k*-th
one is weighted by

```
s_k = r^(k-1) / (1 + r + ... + r^(n-1))
```

so the shares sum to 1 and each extra cafe adds less than the previous one. The category
subscore is `S_c = sum_k s_k * f(d_k)`, between 0 and 1. Example (restaurants & cafes,
n = 10, r = 0.8): one cafe next door is worth 22 % of the category, five cafes 75 %, ten
cafes 100 %.

### 5. Access score

```
A = 100 * sum_c W_c * S_c / sum_c W_c
```

### 6. Network penalties

Measured within `analysis_radius_m` (800 m) of the point:

| Penalty | Metric | No penalty at | Full penalty at | Max |
|---|---|---:|---:|---:|
| Intersection density | nodes where >= 3 street edges meet, per km² | >= 90 /km² | <= 30 /km² | 10 % |
| Block length | mean length of street segments between intersections / dead ends | <= 120 m | >= 240 m | 10 % |
| Slope (only with a DEM) | length-weighted mean absolute grade of those segments | <= 3 % | >= 15 % | 10 % |

Each penalty is linear between its two thresholds. They combine multiplicatively,
`P = 1 - prod_i (1 - P_i)`, capped at `max_total_penalty` (30 %). Penalties are skipped
(and the explanation says so) when the point has no network nearby.

### 7. Final score

```
score = round(A * (1 - P))        # integer 0..100
```

| Score | Rating |
|---:|---|
| 90-100 | Excellent |
| 70-89 | High |
| 50-69 | Moderate |
| 25-49 | Low |
| 0-24 | Very low |

## Profiles

Built-in profiles: `default`, `family` (schools, parks and healthcare weighted up),
`senior` (300 m full credit, 1 km cut-off, healthcare and transit weighted up, stronger slope
penalty) and `transit` (transit stops dominate).

A custom profile overrides a base profile key by key, from a dict or a TOML file:

```toml
# nightlife.toml
[profile]
name = "nightlife"
base = "default"

[profile.decay]
full_m = 300
zero_m = 1200
shape = "linear"

[profile.categories.restaurants_cafes]
weight = 6
max_count = 12

[profile.categories.nightclubs]          # a new category
weight = 2
tags = ["amenity=nightclub", "amenity=music_venue"]
max_count = 3
ratio = 0.6

[profile.penalties.slope]
max_penalty = 0.15
```

```python
from openwalkscore import Profile, score_point

p = Profile.from_toml("nightlife.toml")
p = Profile.from_dict({"categories": {"shopping": {"weight": 0}}})   # switch a category off
score_point(52.52, 13.405, profile=p)
score_point(52.52, 13.405, profile="nightlife.toml")                  # a path works too
```

Unknown keys raise `ValueError`, so a typo never silently changes a score. Set
`replace_categories = true` to start from an empty category list.

## Python API

| Function / class | Purpose |
|---|---|
| `score_point(lat, lon, profile="default", *, data=None, dem=None, engine="python", client=None, use_cache=True, name="")` | Score one point; returns `WalkScoreResult`. |
| `score_grid(bbox, cell_m=250, profile="default", *, data=None, ...)` | Score square cells over `bbox = (west, south, east, north)`; returns a GeoJSON `FeatureCollection` with `score`, `rating`, `sub_<category>` per cell. |
| `compare(points, profile="default", *, data=None, ...)` | Score `(lat, lon)`, `(name, lat, lon)` or `{"name", "lat", "lon"}` items; close points share one download. |
| `WalkScorer(data, profile, dem=None, engine="python")` | Build the network once, call `.score(lat, lon)` / `.grid(bbox, cell_m)` many times. |
| `WalkScoreResult` | `score`, `rating`, `access_score`, `total_penalty`, `subscores`, `categories` (with counted `AmenityHit`s), `penalties`, `metrics`, `distance_mode`, `explain()`, `nearest_amenities()`, `to_dict()`, `to_geojson_feature()`. |
| `Profile`, `get_profile`, `list_profiles` | Profiles and their configuration. |
| `OverpassClient(endpoints, timeout_s, cache_dir, cache_ttl_s, min_interval_s, retries)` | Download control. |
| `load_osm(path_or_obj)` | Read `.osm`, `.geojson`, Overpass `.json`, or a dict. |
| `WalkNetwork`, `dijkstra` | The graph and the shortest-path routine, usable on their own. |
| `DEM.from_ascii_grid(path)`, `DEM.from_function(fn)` | Elevation for the slope penalty. |
| `render_html(obj)`, `write_html(obj, path)` | Leaflet map of a result, a list of results or a grid. |

```python
from openwalkscore import WalkScorer, load_osm

scorer = WalkScorer(load_osm("district.osm"), profile="family")
results = [scorer.score(lat, lon) for lat, lon in listings]       # fast: graph built once
```

Using a DEM:

```python
score_point(41.03, 28.98, dem="istanbul_wgs84.asc")              # ESRI ASCII grid in degrees

import rasterio                                                 # or any sampler you like
src = rasterio.open("dem_wgs84.tif"); band = src.read(1)
def elevation(lat, lon):
    row, col = src.index(lon, lat)
    return float(band[row, col])
score_point(41.03, 28.98, dem=elevation)
```

## Command line

```text
openwalkscore score   --lat LAT --lon LON [--profile P] [--json] [--html map.html]
openwalkscore explain [--lat LAT --lon LON] [--profile P]
openwalkscore grid    --bbox WEST,SOUTH,EAST,NORTH --cell 250 -o grid.geojson [--html map.html]
openwalkscore compare --point "Home,41.03,28.98" --point "Office,41.04,29.00" [--json]
openwalkscore profiles
```

Common options: `--input FILE` (offline `.osm`/`.geojson`/`.json`), `--dem FILE.asc`,
`--engine python|networkx`, `--timeout SECONDS`, `--endpoint URL` (repeatable),
`--no-cache`, `--cache-dir DIR`. Exit codes: 0 ok, 2 invalid input, 3 download failed.

`explain` without coordinates prints the method summary and the full profile.

## Data sources and offline use

**Overpass API (default).** `OverpassClient` sends one query per area (amenities with
`out center`, plus the walkable ways and their nodes) to public Overpass instances, trying
`overpass-api.de`, `overpass.kumi.systems` and `overpass.private.coffee` in turn. It:

* sets the generic User-Agent `openwalkscore/<version>`;
* applies a network timeout (default 60 s) and retries HTTP 429/502/503/504 with back-off
  before failing over to the next mirror;
* waits at least 1 s between requests from the same process;
* caches responses on disk for 7 days (`%LOCALAPPDATA%\openwalkscore\cache`,
  `~/.cache/openwalkscore`, or `OPENWALKSCORE_CACHE_DIR`);
* refuses areas with a diagonal above 25 km. For a whole city, download an extract once
  (e.g. from Geofabrik, then clip with `osmium`) and pass it with `data=` / `--input`.

Please follow the [Overpass usage policy](https://dev.overpass-api.de/overpass-doc/en/preface/commons.html):
keep areas small and avoid parallel bulk jobs.

**Offline input.** `data=` (or `--input`) accepts:

* `.osm` / `.xml` - OSM XML (JOSM, OSM API, `osmium cat -f osm`);
* Overpass JSON (`{"elements": [...]}`) saved from overpass-turbo or the API;
* GeoJSON - points become amenities, `LineString`s with a `highway` property become the
  network (touching vertices are joined), polygons become amenities at their centroid.
  Tags are read from `properties` or `properties.tags`.

## HTML maps

```python
from openwalkscore import score_point, score_grid, write_html
write_html(score_point(40.4168, -3.7038), "madrid.html")
write_html(score_grid((-3.71, 40.41, -3.69, 40.425), 200), "madrid_grid.html")
```

The page loads Leaflet 1.9.4 from unpkg with Subresource Integrity hashes and shows
OpenStreetMap tiles with the required attribution. Data is embedded as JSON with `<`, `>`
and `&` escaped, and all OSM-supplied text (names, tags) is inserted with `textContent`, so
feature names cannot inject markup or script.

## Limitations

* **OSM completeness varies.** Missing shops or footpaths lower the score; a score is a
  statement about the map as much as about the place. Compare places within regions of
  similar mapping quality.
* **Sidewalks mapped as separate footways** add intersections and short segments; the
  density and block-length penalties are therefore lenient in such areas by design (they
  rarely trigger where sidewalks are mapped). Penalties near the edge of a local extract
  can be overstated if the extract does not cover `analysis_radius_m`.
* No crossing safety, traffic speed, sidewalk width, lighting, opening hours, shade,
  crime or amenity quality is modelled.
* The network is treated as undirected and of uniform walking speed; steps and steep grades
  only enter through the optional slope penalty.
* Amenity counts are capped per category; a mall with 200 shops counts as at most 5 shops
  in the default profile.

## Development

```bash
git clone https://github.com/YusufEminoglu/openwalkscore_sdk
cd openwalkscore_sdk
pip install -e ".[dev]"
python -m pytest            # no network needed: tests use hand-made OSM fixtures
python -m ruff check . && python -m ruff format --check src tests && python -m mypy src
```

## Citing

If you use openwalkscore in research, please cite it via [CITATION.cff](CITATION.cff):

> Eminoğlu, Y. (2026). *openwalkscore: open, transparent walkability scores from
> OpenStreetMap* (version 0.1.0) [Software]. https://github.com/YusufEminoglu/openwalkscore_sdk

## License and attribution

Code: MIT, © 2026 Yusuf Eminoğlu. See [LICENSE](LICENSE).

Map data: © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright), available
under the Open Database License (ODbL). When you publish scores or maps derived from OSM
data, credit OpenStreetMap accordingly.

Walk Score® is a registered trademark of its owner; this project is not affiliated with it.
