Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
G.add_node(2, x=13.01, y=52.0)
G.add_edge(1, 2, length=1000.0)

minutes, route = FlatScorer.walk_route(G, (52.0, 13.0), (52.0, 13.01))
minutes, route = FlatScorer.route_time(G, (52.0, 13.0), (52.0, 13.01))
assert route == [(52.0, 13.0), (52.0, 13.01)], route
print(f"routed OK: {minutes:.2f} min")
PY
Expand Down Expand Up @@ -95,7 +95,7 @@ jobs:
G.add_node(2, x=13.01, y=52.0)
G.add_edge(1, 2, length=1000.0)

minutes, route = FlatScorer.walk_route(G, (52.0, 13.0), (52.0, 13.01))
minutes, route = FlatScorer.route_time(G, (52.0, 13.0), (52.0, 13.01))
assert route == [(52.0, 13.0), (52.0, 13.01)], route
print(f"routed OK: {minutes:.2f} min")
PY
Expand Down
214 changes: 177 additions & 37 deletions FlatScorer.py

Large diffs are not rendered by default.

99 changes: 71 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,50 @@ this is just a friendlier way to build the config and view the output.
For each candidate apartment, FlatScorer:

1. **Geocodes** all addresses via Nominatim (through OSMnx).
2. **Downloads** the walking street network and points of interest for the
bounding region from the Overpass API (with automatic mirror failover).
2. **Downloads** a street network per travel mode your destinations actually use
(pedestrian, cycling, or both) plus the points of interest for the bounding
region, from the Overpass API (with automatic mirror failover).
3. **Deduplicates POIs** mapped both as a node and as a building outline
(see [Duplicate POIs](#duplicate-pois)).
4. **Counts nearby amenities** within a configurable radius (default 500 m):
supermarkets, bakeries, pharmacies, gyms, bus/tram stops.
5. **Measures green space** — park and forest polygon area plus point features.
6. **Estimates noise exposure** via distance to the nearest primary/secondary road.
7. **Routes walking commutes** to each of your defined destinations over the
real pedestrian network (~5 km/h).
7. **Routes commutes** to each of your defined destinations over the real
network for that destination's travel mode — walking (~5 km/h) or cycling
(~15 km/h), see [Travel modes](#travel-modes).
8. **Normalizes every metric** onto a common 0–1 scale.
9. **Computes a weighted average** of those normalized values, on a 0–10 scale.

### Travel modes

A destination declares how you get there with `"mode": "walk"` (the default) or
`"mode": "bike"`. It changes the answer substantially: a 35-minute walk is often
a 12-minute cycle, and which flats look good depends on which of those you meant.

```jsonc
"destinations": {
"Office": { "address": "...", "weight": 0.20, "mode": "bike" },
"Supermarket":{ "address": "...", "weight": 0.10 } // walks, as before
}
```

Each mode routes over **its own** street network, downloaded separately. That is
deliberate and not negotiable: the pedestrian graph carries footways a bike may
not use and drops roads it may, so a cycling time computed on it would be wrong
in both directions at once — and wrong invisibly, since it still produces a
plausible number.

The networks are downloaded lazily, one per mode your destinations actually
mention. An all-walk config — every config written before this existed, and the
shipped example — makes exactly one download, as it always did. Only a genuinely
mixed config pays for a second.

Each mode has its own pace (`walking_speed_m_per_min`, `cycling_speed_m_per_min`)
and its own column in the results: `office_bike_min` beside `supermarket_walk_min`,
so a cycled commute is never reported under a heading that says "walk". On the map,
cycled legs are drawn dashed.

### Duplicate POIs

OpenStreetMap frequently maps one real place twice — a supermarket tagged on a
Expand Down Expand Up @@ -195,7 +226,7 @@ thing in every run — and a field of similar flats is allowed to look similar.
|---|---|
| Terminal table | Ranked summary printed to stdout |
| `apartment_scores.csv` | Full metrics for every candidate |
| `apartment_map.html` | Interactive Folium map with color-coded pins and predicted walking routes |
| `apartment_map.html` | Interactive Folium map with color-coded pins and predicted commute routes |
| Sensitivity report | ±20% weight perturbation check on ranking stability |

## Configuration
Expand All @@ -214,11 +245,12 @@ Everything is driven by a single JSON file. Generate a template with
}
],

// Places you commute to — each gets a walking-time column
// Places you commute to — each gets a travel-time column
"destinations": {
"Office": {
"address": "Alexanderplatz 1, 10178 Berlin, Germany",
"weight": 0.20, // importance, relative to the weights below
"mode": "bike", // "walk" (default) or "bike" — see Travel modes
"icon": "briefcase", // FontAwesome icon on the map
"color": "blue"
}
Expand All @@ -241,15 +273,16 @@ Everything is driven by a single JSON file. Generate a template with
"buffer_m": 500, // amenity search radius in meters
"noise_cap_m": 200, // quiet term maxes out at this distance from a busy road
"rent_budget_eur": 2500, // rent at/above this scores 0 on the rent term
"commute_cap_min": 45, // a walk this long scores 0 for that destination
"commute_cap_min": 45, // a commute this long scores 0 for that destination
"walking_speed_m_per_min": 83.33, // assumed pace; 83.33 m/min = 5 km/h
"cycling_speed_m_per_min": 250, // assumed pace; 250 m/min = 15 km/h
"max_bbox_span_km": 30, // refuse to download an area wider than this
"saturation": { // count earning half credit (diminishing returns)
"supermarket": 2, "bakery": 2, "pharmacy": 1,
"gym": 1, "transit": 4, "green": 30
},
"projected_crs": "auto", // auto-detect UTM zone, or e.g. "EPSG:25832"
"show_walk_routes": true // draw predicted walking routes on the map by default
"show_walk_routes": true // draw predicted commute routes on the map by default
},

"output": {
Expand All @@ -266,21 +299,25 @@ Everything is driven by a single JSON file. Generate a template with
linear. Set it to the top of your budget; setting it far above your actual
range flattens the differences between candidates.

- **`commute_cap_min`** — The walk length at which a destination stops earning
- **`commute_cap_min`** — The travel time at which a destination stops earning
anything. Same shape as the rent term, including the same tradeoff: everything
past the cap scores 0, so a 50-minute walk and a 90-minute walk are
indistinguishable on that term. If the score breakdown shows a destination at
0.00 for every candidate, raise the cap (or accept that nobody is walking
there). Both anchors are deliberately absolute — that is what makes a score
mean the same thing between runs.

- **`walking_speed_m_per_min`** — The pace every routed distance is divided by to
get minutes. The default 83.33 m/min is 5 km/h, the usual planning figure for
an unhurried adult on the flat; 100 m/min (6 km/h) is a brisk walker. It only
means anything alongside `commute_cap_min`, because the two multiply out: a
slower pace makes every walk longer in minutes and so pushes more destinations
towards the cap. If you change one, sanity-check the other — the routed
distances themselves have not moved.
past the cap scores 0, so a 50-minute commute and a 90-minute commute are
indistinguishable on that term. One cap covers every mode — it is how long you
are willing to travel, not how far. If the score breakdown shows a destination
at 0.00 for every candidate, raise the cap, switch that destination to `bike`,
or accept that nobody is getting there. Both anchors are deliberately absolute
— that is what makes a score mean the same thing between runs.

- **`walking_speed_m_per_min` / `cycling_speed_m_per_min`** — The pace each mode's
routed distances are divided by to get minutes. The walking default 83.33 m/min
is 5 km/h, the usual planning figure for an unhurried adult on the flat; 100
m/min (6 km/h) is a brisk walker. The cycling default 250 m/min is 15 km/h,
urban cycling *including* junctions, lights and locking up — not the speed a fit
rider holds on a clear path, which is why it is well under what a bike computer
reports. Both only mean anything alongside `commute_cap_min`, because they
multiply out: a slower pace makes every commute longer in minutes and so pushes
more destinations towards the cap. If you change one, sanity-check the other —
the routed distances themselves have not moved.

- **`saturation`** — The count that earns half credit for each amenity, i.e. how
quickly more of something stops helping. Lower is easier to satisfy: at
Expand Down Expand Up @@ -313,11 +350,16 @@ Everything is driven by a single JSON file. Generate a template with
destination at 0.15 against weights totalling 1.5 controls 10% of the score —
at most 1 point out of 10, earned by living next door to it.

- **`show_walk_routes`** — Whether the map's "Predicted walking routes" layer
starts visible. The routes trace each candidate's actual shortest path over
the OSM pedestrian network to every destination (color-matched to that
candidate's score) and can always be toggled via the map's layer control
regardless of this setting.
- **Destination `mode`** — `"walk"` (the default) or `"bike"`, deciding which
street network this commute is routed over and which pace it is divided by.
See [Travel modes](#travel-modes); omitting it walks, so every config written
before cycling existed scores exactly as it did.

- **`show_walk_routes`** — Whether the map's predicted-routes layer starts
visible. The routes trace each candidate's actual shortest path to every
destination over that destination's own network (color-matched to the
candidate's score, dashed for cycled legs) and can always be toggled via the
map's layer control regardless of this setting.

### Configuration is validated before anything runs

Expand All @@ -340,7 +382,8 @@ doesn't score neutrally — it scores *perfectly* on that term and tends to win.
So a missing or non-positive rent is rejected rather than guessed at. The other
checks cover missing names and addresses, duplicate candidate names (they'd
silently overwrite each other), non-numeric or negative weights, an all-zero
weight vector, and non-positive normalization anchors.
weight vector, non-positive normalization anchors, and an unrecognised
destination travel `mode`.

In the GUI the same problems appear on the Run page and the run button stays
disabled until they're fixed.
Expand Down
3 changes: 3 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
"White House": {
"address": "1600 Pennsylvania Ave NW, Washington, DC 20500, USA",
"weight": 0.15,
"mode": "walk",
"icon": "landmark",
"color": "blue"
},
"Union Station": {
"address": "50 Massachusetts Ave NE, Washington, DC 20002, USA",
"weight": 0.15,
"mode": "walk",
"icon": "train",
"color": "red"
}
Expand All @@ -46,6 +48,7 @@
"rent_budget_eur": 2500,
"commute_cap_min": 45,
"walking_speed_m_per_min": 83.33,
"cycling_speed_m_per_min": 250,
"poi_dedupe_tolerance_m": 10,
"max_bbox_span_km": 30,
"saturation": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "flatscorer"
version = "0.1.0"
description = "Score and compare candidate apartments using OpenStreetMap data — amenities, transit, green space, road noise, walking commutes, and rent."
description = "Score and compare candidate apartments using OpenStreetMap data — amenities, transit, green space, road noise, walking and cycling commutes, and rent."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
Expand Down
44 changes: 39 additions & 5 deletions streamlit_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
from FlatScorer import (
DEFAULT_CONFIG,
DEFAULT_DEST_WEIGHT,
DEFAULT_TRAVEL_MODE,
NARROW_MARGIN_THRESHOLD,
SCORE_SCALE_MAX,
TRAVEL_MODES,
FlatScorer,
SearchAreaError,
validate_config,
Expand Down Expand Up @@ -60,6 +62,11 @@
DEFAULT_WEIGHTS = DEFAULT_CONFIG["weights"]
DEFAULT_PARAMS = DEFAULT_CONFIG["parameters"]

# Travel modes come from the engine rather than a literal here, so the dropdown
# can never offer a mode validate_config would reject.
MODE_CHOICES = list(TRAVEL_MODES)
MODE_EMOJI = {"walk": "🚶", "bike": "🚴"}


# ----------------------------------------------------------- Helper Functions --

Expand All @@ -76,6 +83,7 @@ def _init_state():
"name": name,
"address": info["address"],
"weight": info["weight"],
"mode": info.get("mode", DEFAULT_TRAVEL_MODE),
"icon": info.get("icon", "star"),
"color": info.get("color", "blue"),
})
Expand Down Expand Up @@ -122,6 +130,7 @@ def _load_config_into_state(config: dict[str, Any]):
"name": name,
"address": info.get("address", ""),
"weight": info.get("weight", 0.15),
"mode": info.get("mode", DEFAULT_TRAVEL_MODE),
"icon": info.get("icon", "star"),
"color": info.get("color", "blue"),
})
Expand Down Expand Up @@ -159,6 +168,9 @@ def _build_config() -> dict[str, Any]:
destinations[row["name"]] = {
"address": row["address"],
"weight": float(row.get("weight", 0.15) or 0.15),
# A cleared mode cell arrives as None/NaN; fall back rather than
# writing a mode the engine would reject.
"mode": row.get("mode") if row.get("mode") in TRAVEL_MODES else DEFAULT_TRAVEL_MODE,
"icon": row.get("icon", "star") or "star",
"color": row.get("color", "blue") or "blue",
}
Expand Down Expand Up @@ -653,7 +665,8 @@ def _inject_custom_theme():
<div class="fs-card">
<div class="fs-card-title">📍 Commute Destinations</div>
<div class="fs-card-desc">
Places you frequently travel to (e.g. work, university, gym). Each destination incurs a walking-time penalty scaled by its weight.
Places you frequently travel to (e.g. work, university, gym). Each destination incurs a travel-time penalty scaled by its weight,
routed over the real network for its travel mode — walking or cycling. Mixing modes adds one extra OpenStreetMap download.
</div>
</div>
""",
Expand All @@ -672,6 +685,12 @@ def _inject_custom_theme():
help="Relative importance of this commute, competing in the same pool as the "
"amenity weights. See the influence table on the Weights page.",
),
"mode": st.column_config.SelectboxColumn(
"Travel Mode", options=MODE_CHOICES, required=True,
help="How you get there. Each mode routes over its own street network at its own "
"pace (set on the Weights & Parameters page) — a 35-minute walk is often a "
"12-minute cycle. Using both modes costs one extra OpenStreetMap download.",
),
"icon": st.column_config.SelectboxColumn("Map Icon", options=ICON_CHOICES),
"color": st.column_config.SelectboxColumn("Map Color", options=COLOR_CHOICES),
},
Expand Down Expand Up @@ -753,7 +772,8 @@ def _inject_custom_theme():
if not dest_name:
continue
combined[f"dest_{dest_name}"] = float(row.get("weight", DEFAULT_DEST_WEIGHT) or 0.0)
dest_labels[f"dest_{dest_name}"] = f"🚶 Commute to {dest_name}"
mode = row.get("mode") if row.get("mode") in TRAVEL_MODES else DEFAULT_TRAVEL_MODE
dest_labels[f"dest_{dest_name}"] = f"{MODE_EMOJI.get(mode, '🚶')} Commute to {dest_name}"

shares = weight_shares(combined)
if sum(combined.values()) <= 0:
Expand Down Expand Up @@ -836,7 +856,7 @@ def _inject_custom_theme():
help="Distance from a busy road at which the quiet term maxes out. Raising it no longer inflates noise's influence — the term is scaled by the cap.",
)

p5, p6, p7 = st.columns([2, 1, 1])
p5, p6, p7, p8 = st.columns([2, 1, 1, 1])
with p5:
st.session_state.params["projected_crs"] = st.text_input(
"Projected CRS",
Expand All @@ -862,11 +882,25 @@ def _inject_custom_theme():
)
st.session_state.params["walking_speed_m_per_min"] = walking_speed
st.caption(f"≈ {walking_speed * 60 / 1000:.1f} km/h")
with p8:
cycling_speed = st.number_input(
"Cycling speed (m/min)",
min_value=1.0,
value=float(st.session_state.params.get(
"cycling_speed_m_per_min", DEFAULT_PARAMS["cycling_speed_m_per_min"])),
step=10.0,
help="Pace used for destinations set to 'bike' on the Destinations page. The default "
"250 m/min (15 km/h) is urban cycling including junctions and locking up, not "
"open-road speed.",
)
st.session_state.params["cycling_speed_m_per_min"] = cycling_speed
st.caption(f"≈ {cycling_speed * 60 / 1000:.1f} km/h")

st.session_state.params["show_walk_routes"] = st.checkbox(
"Show predicted walking routes on map by default",
"Show predicted commute routes on map by default",
value=bool(st.session_state.params.get("show_walk_routes", True)),
help="Draws each candidate's shortest walking path to every destination on the map. Always toggleable via the map's layer control.",
help="Draws each candidate's shortest path to every destination on the map, over that destination's own network. "
"Cycled legs are dashed. Always toggleable via the map's layer control.",
)


Expand Down
Loading
Loading