Skip to content
Draft
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
169 changes: 169 additions & 0 deletions analyze_commute_od.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""
Commute OD (Origin-Destination) Traffic Analysis - Pendeln
Analyzes commuting flows between zones: volumes, directions, peaks, and top corridors.
"""

import random
import math
from collections import defaultdict

random.seed(42)

# --- Zones (districts) ---
ZONES = {
"Z01": "Mitte",
"Z02": "Prenzlauer Berg",
"Z03": "Friedrichshain",
"Z04": "Kreuzberg",
"Z05": "Schöneberg",
"Z06": "Tempelhof",
"Z07": "Neukölln",
"Z08": "Treptow",
"Z09": "Pankow",
"Z10": "Weissensee",
}

# Zone centroids (x, y) in km
CENTROIDS = {
"Z01": (0.0, 0.0),
"Z02": (1.5, 3.0),
"Z03": (3.0, 1.0),
"Z04": (1.0, -2.0),
"Z05": (-2.0, -1.5),
"Z06": (-1.0, -5.0),
"Z07": (2.0, -4.0),
"Z08": (5.0, -3.0),
"Z09": (1.0, 6.0),
"Z10": (4.0, 5.0),
}

# Employment attractiveness (higher = more jobs → more in-commuters)
ATTRACTIVENESS = {
"Z01": 3.0, "Z02": 1.2, "Z03": 1.5, "Z04": 1.4,
"Z05": 1.1, "Z06": 0.8, "Z07": 0.9, "Z08": 0.7,
"Z09": 1.0, "Z10": 0.6,
}

ZONE_IDS = list(ZONES.keys())


def distance(z1: str, z2: str) -> float:
x1, y1 = CENTROIDS[z1]
x2, y2 = CENTROIDS[z2]
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)


def gravity_trips(origin: str, dest: str, base: int = 800) -> int:
"""Gravity model: trips ∝ attractiveness / distance²."""
if origin == dest:
return 0
d = max(distance(origin, dest), 0.5)
raw = base * ATTRACTIVENESS[dest] / (d ** 1.8)
noise = random.gauss(1.0, 0.12)
return max(0, int(raw * noise))


# --- Build OD matrix ---
od_matrix: dict[tuple[str, str], int] = {}
for o in ZONE_IDS:
for d in ZONE_IDS:
if o != d:
od_matrix[(o, d)] = gravity_trips(o, d)

# --- Summary statistics ---
total_trips = sum(od_matrix.values())
num_pairs = len(od_matrix)

print("=" * 60)
print(" COMMUTE OD TRAFFIC ANALYSIS – PENDELN")
print("=" * 60)
print(f"\nZones analysed : {len(ZONES)}")
print(f"OD pairs : {num_pairs}")
print(f"Total trips : {total_trips:,}")
print(f"Avg trips/pair : {total_trips / num_pairs:,.1f}")

# --- Top 10 OD corridors ---
print("\n--- Top 10 OD Corridors ---")
top_pairs = sorted(od_matrix.items(), key=lambda x: x[1], reverse=True)[:10]
print(f"{'Rank':<5} {'Origin':<20} {'Destination':<20} {'Trips':>8} {'Dist(km)':>9}")
print("-" * 65)
for rank, ((o, d), trips) in enumerate(top_pairs, 1):
dist = distance(o, d)
print(f"{rank:<5} {ZONES[o]:<20} {ZONES[d]:<20} {trips:>8,} {dist:>9.2f}")

# --- Zone-level totals ---
departures: dict[str, int] = defaultdict(int)
arrivals: dict[str, int] = defaultdict(int)

for (o, d), trips in od_matrix.items():
departures[o] += trips
arrivals[d] += trips

print("\n--- Zone Summary (Departures / Arrivals / Net) ---")
print(f"{'Zone':<20} {'Departures':>12} {'Arrivals':>10} {'Net (Arr-Dep)':>14}")
print("-" * 58)
for zid in ZONE_IDS:
dep = departures[zid]
arr = arrivals[zid]
net = arr - dep
bar = "▲" if net > 0 else "▼"
print(f"{ZONES[zid]:<20} {dep:>12,} {arr:>10,} {net:>+13,} {bar}")

# --- Intra-zone share ---
intra = sum(gravity_trips(z, z, 800) for z in ZONE_IDS) # all zero by design
print(f"\nIntra-zone trips: {intra:,} (internal commutes excluded)")

# --- Distance distribution ---
dist_buckets: dict[str, int] = defaultdict(int)
for (o, d), trips in od_matrix.items():
d_km = distance(o, d)
if d_km < 3:
bucket = "< 3 km (short)"
elif d_km < 6:
bucket = "3–6 km (medium)"
else:
bucket = "> 6 km (long)"
dist_buckets[bucket] += trips

print("\n--- Trips by Distance Band ---")
for band, count in sorted(dist_buckets.items()):
share = 100 * count / total_trips
bar = "█" * int(share / 2)
print(f" {band:<22} {count:>8,} ({share:5.1f}%) {bar}")

# --- Directional balance (symmetry) ---
print("\n--- Directional Balance (top asymmetric pairs) ---")
asymmetry: list[tuple[float, str, str, int, int]] = []
checked: set[frozenset] = set()
for (o, d) in od_matrix:
key = frozenset([o, d])
if key in checked:
continue
checked.add(key)
fwd = od_matrix.get((o, d), 0)
bwd = od_matrix.get((d, o), 0)
if fwd + bwd == 0:
continue
ratio = max(fwd, bwd) / (min(fwd, bwd) + 1)
asymmetry.append((ratio, o, d, fwd, bwd))

asymmetry.sort(reverse=True)
print(f"{'Pair':<35} {'A→B':>8} {'B→A':>8} {'Ratio':>7}")
print("-" * 60)
for ratio, o, d, fwd, bwd in asymmetry[:8]:
pair = f"{ZONES[o]} ↔ {ZONES[d]}"
print(f"{pair:<35} {fwd:>8,} {bwd:>8,} {ratio:>7.2f}x")

# --- Key findings ---
top_origin = max(departures, key=departures.get)
top_dest = max(arrivals, key=arrivals.get)
top_od = top_pairs[0]

print("\n--- Key Findings ---")
print(f" Strongest origin zone : {ZONES[top_origin]} ({departures[top_origin]:,} departures)")
print(f" Strongest dest. zone : {ZONES[top_dest]} ({arrivals[top_dest]:,} arrivals)")
print(f" Busiest corridor : {ZONES[top_od[0][0]]} → {ZONES[top_od[0][1]]} ({top_od[1]:,} trips)")
short_pct = 100 * dist_buckets["< 3 km (short)"] / total_trips
print(f" Short-distance share : {short_pct:.1f}% of all commutes are < 3 km")
print()
print("Analysis complete.")
221 changes: 221 additions & 0 deletions commute_od_report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commute OD Traffic Analysis – Pendeln</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', sans-serif; background: #0f1117; color: #e0e0e0; padding: 24px; }
h1 { text-align: center; font-size: 1.6rem; color: #fff; margin-bottom: 4px; }
.subtitle { text-align: center; color: #888; font-size: 0.9rem; margin-bottom: 28px; }

.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 28px; }
.kpi { background: #1a1d27; border: 1px solid #2a2d3e; border-radius: 10px; padding: 18px 16px; text-align: center; }
.kpi .value { font-size: 2rem; font-weight: 700; color: #4f9eff; }
.kpi .label { font-size: 0.75rem; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: .5px; }

.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.card { background: #1a1d27; border: 1px solid #2a2d3e; border-radius: 10px; padding: 20px; }
.card h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: .8px; color: #aaa; margin-bottom: 14px; border-bottom: 1px solid #2a2d3e; padding-bottom: 8px; }

table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { color: #888; font-weight: 600; text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a2d3e; font-size: 0.75rem; text-transform: uppercase; }
td { padding: 7px 8px; border-bottom: 1px solid #1f2235; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #1f2235; }
.rank { color: #555; font-size: 0.8rem; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.pos { color: #4ade80; }
.neg { color: #f87171; }
.arrow-up { color: #4ade80; }
.arrow-dn { color: #f87171; }

.bar-wrap { display: flex; align-items: center; gap: 8px; }
.bar-track { flex: 1; background: #0f1117; border-radius: 4px; height: 10px; }
.bar-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #4f9eff, #7b5ea7); }
.bar-label { font-size: 0.78rem; color: #aaa; width: 48px; text-align: right; }

.dist-row { margin-bottom: 14px; }
.dist-name { font-size: 0.82rem; color: #ccc; margin-bottom: 4px; }
.dist-bar-track { width: 100%; background: #0f1117; border-radius: 4px; height: 14px; position: relative; }
.dist-bar-fill { height: 100%; border-radius: 4px; }
.dist-short { background: linear-gradient(90deg, #4ade80, #22c55e); }
.dist-medium { background: linear-gradient(90deg, #4f9eff, #3b82f6); }
.dist-long { background: linear-gradient(90deg, #f87171, #ef4444); }
.dist-pct { font-size: 0.78rem; color: #aaa; margin-top: 3px; }

.ratio-badge { display: inline-block; background: #2a1f3d; color: #c084fc; border-radius: 4px; padding: 2px 7px; font-size: 0.78rem; font-weight: 700; }

@media (max-width: 700px) {
.kpi-grid { grid-template-columns: 1fr 1fr; }
.grid2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>

<h1>Commute OD Traffic Analysis</h1>
<p class="subtitle">Pendeln — Origin-Destination flows across 10 city zones &nbsp;|&nbsp; Gravity model</p>

<!-- KPI cards -->
<div class="kpi-grid">
<div class="kpi"><div class="value">10</div><div class="label">Zones</div></div>
<div class="kpi"><div class="value">90</div><div class="label">OD Pairs</div></div>
<div class="kpi"><div class="value">7,044</div><div class="label">Total Trips</div></div>
<div class="kpi"><div class="value">78.3</div><div class="label">Avg Trips / Pair</div></div>
</div>

<div class="grid2">

<!-- Top 10 corridors -->
<div class="card">
<h2>Top 10 OD Corridors</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Origin</th>
<th>Destination</th>
<th class="num">Trips</th>
<th class="num">km</th>
<th style="width:120px">Volume</th>
</tr>
</thead>
<tbody id="corridors"></tbody>
</table>
</div>

<!-- Zone summary -->
<div class="card">
<h2>Zone Departure / Arrival Balance</h2>
<table>
<thead>
<tr>
<th>Zone</th>
<th class="num">Dep.</th>
<th class="num">Arr.</th>
<th class="num">Net</th>
</tr>
</thead>
<tbody id="zones"></tbody>
</table>
</div>

</div>

<div class="grid2">

<!-- Distance bands -->
<div class="card">
<h2>Trips by Distance Band</h2>
<div id="dist"></div>
</div>

<!-- Directional balance -->
<div class="card">
<h2>Directional Asymmetry (top pairs)</h2>
<table>
<thead>
<tr>
<th>Pair</th>
<th class="num">A→B</th>
<th class="num">B→A</th>
<th class="num">Ratio</th>
</tr>
</thead>
<tbody id="asym"></tbody>
</table>
</div>

</div>

<script>
const corridors = [
["Kreuzberg","Mitte",580,2.24],
["Schöneberg","Mitte",497,2.50],
["Neukölln","Kreuzberg",308,2.24],
["Friedrichshain","Mitte",303,3.16],
["Prenzlauer Berg","Mitte",275,3.35],
["Mitte","Kreuzberg",259,2.24],
["Prenzlauer Berg","Friedrichshain",237,2.50],
["Mitte","Schöneberg",183,2.50],
["Friedrichshain","Prenzlauer Berg",182,2.50],
["Mitte","Friedrichshain",147,3.16],
];
const maxTrips = corridors[0][2];
const tbody = document.getElementById("corridors");
corridors.forEach(([o,d,t,km],i)=>{
const pct = (t/maxTrips*100).toFixed(1);
tbody.innerHTML += `<tr>
<td class="rank">${i+1}</td>
<td>${o}</td>
<td>${d}</td>
<td class="num">${t.toLocaleString()}</td>
<td class="num">${km}</td>
<td><div class="bar-wrap"><div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div></div></td>
</tr>`;
});

const zones = [
["Mitte",837,2207],
["Prenzlauer Berg",849,670],
["Friedrichshain",809,852],
["Kreuzberg",1102,1126],
["Schöneberg",873,583],
["Tempelhof",494,303],
["Neukölln",792,462],
["Treptow",452,261],
["Pankow",400,351],
["Weissensee",436,229],
];
const ztbody = document.getElementById("zones");
zones.forEach(([z,dep,arr])=>{
const net = arr - dep;
const cls = net >= 0 ? "pos" : "neg";
const arrow = net >= 0 ? "▲" : "▼";
ztbody.innerHTML += `<tr>
<td>${z}</td>
<td class="num">${dep.toLocaleString()}</td>
<td class="num">${arr.toLocaleString()}</td>
<td class="num ${cls}">${net >= 0 ? "+" : ""}${net.toLocaleString()} ${arrow}</td>
</tr>`;
});

const distData = [
["< 3 km (short)","dist-short",2361,33.5],
["3–6 km (medium)","dist-medium",3919,55.6],
["> 6 km (long)","dist-long",764,10.8],
];
const distDiv = document.getElementById("dist");
distData.forEach(([label,cls,count,pct])=>{
distDiv.innerHTML += `<div class="dist-row">
<div class="dist-name">${label} &nbsp;<strong>${count.toLocaleString()}</strong> trips</div>
<div class="dist-bar-track"><div class="dist-bar-fill ${cls}" style="width:${pct}%"></div></div>
<div class="dist-pct">${pct}% of all commutes</div>
</div>`;
});

const asym = [
["Mitte","Weissensee",16,81,4.76],
["Mitte","Tempelhof",33,133,3.91],
["Mitte","Treptow",24,94,3.76],
["Mitte","Neukölln",39,145,3.62],
["Mitte","Pankow",30,99,3.19],
["Mitte","Schöneberg",183,497,2.70],
["Kreuzberg","Neukölln",115,308,2.66],
["Mitte","Prenzlauer Berg",106,275,2.57],
];
const atbody = document.getElementById("asym");
asym.forEach(([a,b,fwd,bwd,ratio])=>{
atbody.innerHTML += `<tr>
<td>${a} ↔ ${b}</td>
<td class="num">${fwd.toLocaleString()}</td>
<td class="num">${bwd.toLocaleString()}</td>
<td class="num"><span class="ratio-badge">${ratio}x</span></td>
</tr>`;
});
</script>

</body>
</html>
Loading