mahbubchula commited on
Commit
4a67235
·
verified ·
1 Parent(s): 458c275

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +145 -0
  2. fourstep_synthetic.py +577 -0
  3. requirements.txt +10 -0
app.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # TripAI – Intelligent Four-Step Travel Demand Modelling
3
+ # Main Entry Point for the Multi-Page Streamlit Application
4
+
5
+ import streamlit as st
6
+
7
+ st.set_page_config(
8
+ page_title="TripAI – Intelligent Four-Step Travel Demand Model",
9
+ page_icon="🚦",
10
+ layout="wide"
11
+ )
12
+
13
+ # ==========================================================
14
+ # HEADER
15
+ # ==========================================================
16
+ st.title("🚦 TripAI")
17
+ st.markdown("### Intelligent Four-Step Travel Demand Modelling with AI, XAI, and Optimization")
18
+
19
+ st.markdown(
20
+ """
21
+ TripAI is a **research-oriented platform** implementing a complete, synthetic
22
+ **four-step travel demand model**, augmented with:
23
+
24
+ - Classical **Trip Generation → Trip Distribution → Mode Choice → Route Assignment**
25
+ - **User Equilibrium (UE)** using Frank–Wolfe
26
+ - **Machine Learning** (Regression + Classification)
27
+ - **Explainable AI** (SHAP) for behavioural insights
28
+ - **AI Link Flow Emulator** for fast demand scaling
29
+ - **Policy Scenario Engine** with congestion charge, TOD, MRT improvements
30
+ - **Scenario Optimization** over policy parameters
31
+
32
+ Use the **left sidebar** to navigate between phases of the workflow.
33
+ """
34
+ )
35
+
36
+ # ==========================================================
37
+ # SESSION STATUS PANEL
38
+ # ==========================================================
39
+ st.markdown("---")
40
+ st.subheader("📊 Current Session Status")
41
+
42
+ col1, col2, col3 = st.columns(3)
43
+
44
+ # ----- Column 1 -----
45
+ with col1:
46
+ st.markdown("**1. Synthetic City**")
47
+ if "city" in st.session_state:
48
+ taz = st.session_state["city"].taz
49
+ st.success(f"Generated ({len(taz)} TAZs)")
50
+ st.caption("Go to: `📊 Generate Synthetic City`")
51
+ else:
52
+ st.info("Not generated")
53
+
54
+ st.markdown("**2. Trip Generation**")
55
+ if "productions" in st.session_state and "attractions" in st.session_state:
56
+ st.success("Done")
57
+ st.caption("Go to: `🚶 Trip Generation`")
58
+ else:
59
+ st.info("Not run")
60
+
61
+ # ----- Column 2 -----
62
+ with col2:
63
+ st.markdown("**3. Trip Distribution**")
64
+ if "od" in st.session_state:
65
+ st.success("OD matrices available")
66
+ st.caption("Go to: `🌍 Trip Distribution`")
67
+ else:
68
+ st.info("Not run")
69
+
70
+ st.markdown("**4. Mode Choice**")
71
+ if "mode_choice" in st.session_state:
72
+ st.success("Mode choice available")
73
+ st.caption("Go to: `🚈 Mode Choice`")
74
+ else:
75
+ st.info("Not run")
76
+
77
+ # ----- Column 3 -----
78
+ with col3:
79
+ st.markdown("**5. Route Assignment**")
80
+ if "link_flows" in st.session_state:
81
+ st.success("Assignment complete")
82
+ st.caption("Go to: `🛣️ Route Assignment`")
83
+ else:
84
+ st.info("Not run")
85
+
86
+ st.markdown("**6. AI / Scenario / Visualization**")
87
+
88
+ status = []
89
+ if "ai_tripgen_model" in st.session_state:
90
+ status.append("AI TripGen")
91
+ if "ai_modechoice_model" in st.session_state:
92
+ status.append("AI ModeChoice")
93
+ if "link_flow_emulator" in st.session_state:
94
+ status.append("AI Emulator")
95
+ if "opt_results" in st.session_state:
96
+ status.append("Optimization")
97
+
98
+ if status:
99
+ st.success(" / ".join(status))
100
+ st.caption("See: `🤖 AI`, `🧠 Emulator`, `🎯 Optimization`, `📈 Visualization`")
101
+ else:
102
+ st.info("No AI/Scenario modules executed")
103
+
104
+ # ==========================================================
105
+ # WORKFLOW EXPLANATION
106
+ # ==========================================================
107
+ st.markdown("---")
108
+ st.subheader("🧭 Recommended Workflow")
109
+
110
+ st.markdown(
111
+ """
112
+ 1. **📊 Generate Synthetic City**
113
+ Build a 20-zone synthetic metro with socio-economic + land-use attributes.
114
+
115
+ 2. **🚶 Trip Generation**
116
+ Compute productions & attractions for HBW, HBE, HBS.
117
+
118
+ 3. **🌍 Trip Distribution**
119
+ Doubly-constrained gravity model with IPF.
120
+
121
+ 4. **🚈 Mode Choice**
122
+ Multinomial Logit (Car / Metro / Bus).
123
+
124
+ 5. **🛣️ Route Assignment**
125
+ AON or User Equilibrium (Frank–Wolfe).
126
+
127
+ 6. **🤖 AI-Enhanced Models**
128
+ ML Regression + Classification + SHAP explanations.
129
+
130
+ 7. **⚙️ Policy Scenario Engine**
131
+ Metro improvements, congestion charge, fare changes, TOD.
132
+
133
+ 8. **🧠 AI Link Flow Emulator**
134
+ Predict link flows without running UE.
135
+
136
+ 9. **🎯 Scenario Optimization**
137
+ Search policy space to minimize congestion or car use.
138
+
139
+ 10. **📈 Visualization & 📦 Export**
140
+ Create research-grade figures & download complete datasets.
141
+ """
142
+ )
143
+
144
+ st.markdown("---")
145
+ st.caption("TripAI – Developed by Mahbub Hassan, B’Deshi Emerging Research Lab.")
fourstep_synthetic.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fourstep_synthetic.py
3
+
4
+ Synthetic four-step travel demand model for a 20-TAZ city.
5
+ Stage 1: classical model on synthetic data (no AI yet).
6
+
7
+ Author: (Your Name)
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import numpy as np
12
+ import pandas as pd
13
+ from dataclasses import dataclass
14
+ from typing import Dict, Tuple
15
+ import networkx as nx
16
+
17
+ # -------------------------------------------------
18
+ # GLOBAL SETTINGS
19
+ # -------------------------------------------------
20
+
21
+ RANDOM_SEED = 42
22
+ NUM_ZONES = 20
23
+
24
+ rng = np.random.default_rng(RANDOM_SEED)
25
+
26
+ # -------------------------------------------------
27
+ # 1. SYNTHETIC CITY GENERATOR (TAZ-LEVEL DATA)
28
+ # -------------------------------------------------
29
+
30
+ @dataclass
31
+ class SyntheticCity:
32
+ taz: pd.DataFrame # zone attributes
33
+ distance_matrix: pd.DataFrame # minutes between TAZs (symmetric)
34
+ travel_time_matrix: pd.DataFrame # base car travel time (minutes)
35
+
36
+
37
+ def generate_synthetic_city(num_zones: int = NUM_ZONES,
38
+ seed: int = RANDOM_SEED) -> SyntheticCity:
39
+ """
40
+ Generate synthetic socio-economic and spatial data for a set of TAZs.
41
+
42
+ Returns
43
+ -------
44
+ SyntheticCity
45
+ """
46
+ rng_local = np.random.default_rng(seed)
47
+
48
+ # Create synthetic 2D coordinates for zones (km), roughly a 10x10 km city
49
+ x = rng_local.uniform(0, 10, size=num_zones)
50
+ y = rng_local.uniform(0, 10, size=num_zones)
51
+
52
+ # Population and households
53
+ population = rng_local.normal(loc=25000, scale=5000, size=num_zones)
54
+ population = np.clip(population, 8000, None).astype(int)
55
+
56
+ households = (population / rng_local.normal(loc=3.2, scale=0.3,
57
+ size=num_zones)).astype(int)
58
+
59
+ # Workers and students
60
+ workers = (population * rng_local.uniform(0.35, 0.45, size=num_zones)).astype(int)
61
+ students = (population * rng_local.uniform(0.2, 0.3, size=num_zones)).astype(int)
62
+
63
+ # Income (monthly, arbitrary units) – lognormal
64
+ income = rng_local.lognormal(mean=10, sigma=0.4, size=num_zones)
65
+
66
+ # Car ownership rate as sigmoid of income
67
+ def sigmoid(z):
68
+ return 1 / (1 + np.exp(-z))
69
+
70
+ car_ownership_rate = sigmoid(0.00003 * income - 3.0)
71
+ cars = (car_ownership_rate * households * rng_local.uniform(0.8, 1.2,
72
+ size=num_zones)).astype(int)
73
+
74
+ # Land-use mix index (0–1)
75
+ land_use_mix = rng_local.uniform(0.2, 0.9, size=num_zones)
76
+
77
+ # Jobs and floor areas
78
+ service_jobs = (workers * rng_local.uniform(0.8, 1.4, size=num_zones)).astype(int)
79
+ industrial_jobs = (workers * rng_local.uniform(0.3, 0.8, size=num_zones)).astype(int)
80
+ retail_jobs = (workers * rng_local.uniform(0.3, 0.7, size=num_zones)).astype(int)
81
+
82
+ school_capacity = (students * rng_local.uniform(1.1, 1.5, size=num_zones)).astype(int)
83
+ retail_floor_area = (retail_jobs * rng_local.uniform(20, 40, size=num_zones)) # arbitrary units
84
+
85
+ taz_df = pd.DataFrame({
86
+ "TAZ": np.arange(1, num_zones + 1),
87
+ "x_km": x,
88
+ "y_km": y,
89
+ "population": population,
90
+ "households": households,
91
+ "workers": workers,
92
+ "students": students,
93
+ "income": income,
94
+ "car_ownership_rate": car_ownership_rate,
95
+ "cars": cars,
96
+ "land_use_mix": land_use_mix,
97
+ "service_jobs": service_jobs,
98
+ "industrial_jobs": industrial_jobs,
99
+ "retail_jobs": retail_jobs,
100
+ "school_capacity": school_capacity,
101
+ "retail_floor_area": retail_floor_area,
102
+ })
103
+
104
+ taz_df.set_index("TAZ", inplace=True)
105
+
106
+ # Distance matrix (Euclidean) and base car travel time (min)
107
+ coords = taz_df[["x_km", "y_km"]].to_numpy()
108
+ dx = coords[:, 0][:, None] - coords[:, 0][None, :]
109
+ dy = coords[:, 1][:, None] - coords[:, 1][None, :]
110
+ dist_km = np.sqrt(dx ** 2 + dy ** 2)
111
+
112
+ # Assume average car speed ~ 25–35 km/h plus 3–8 minutes terminal time
113
+ avg_speed_kmh = rng_local.uniform(25, 35)
114
+ tt_base = (dist_km / avg_speed_kmh) * 60 # minutes
115
+ tt_matrix = tt_base + rng_local.uniform(3, 8, size=(num_zones, num_zones))
116
+
117
+ # Ensure diagonal is small (intra-zonal trips)
118
+ np.fill_diagonal(tt_matrix, rng_local.uniform(3, 5, size=num_zones))
119
+ np.fill_diagonal(dist_km, rng_local.uniform(0.2, 0.5, size=num_zones))
120
+
121
+ distance_df = pd.DataFrame(dist_km,
122
+ index=taz_df.index,
123
+ columns=taz_df.index)
124
+ tt_df = pd.DataFrame(tt_matrix,
125
+ index=taz_df.index,
126
+ columns=taz_df.index)
127
+
128
+ return SyntheticCity(taz=taz_df,
129
+ distance_matrix=distance_df,
130
+ travel_time_matrix=tt_df)
131
+
132
+ # -------------------------------------------------
133
+ # 2. TRIP GENERATION
134
+ # -------------------------------------------------
135
+
136
+ PURPOSES = ["HBW", "HBE", "HBS"]
137
+
138
+
139
+ def trip_generation(taz: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
140
+ """
141
+ Generate synthetic trip productions and attractions by purpose.
142
+
143
+ Parameters
144
+ ----------
145
+ taz : DataFrame
146
+ TAZ-level socio-economic attributes.
147
+
148
+ Returns
149
+ -------
150
+ productions : DataFrame (index=TAZ, columns=PURPOSES)
151
+ attractions : DataFrame (index=TAZ, columns=PURPOSES)
152
+ """
153
+ df = taz
154
+
155
+ # Productions (synthetic "true" equations)
156
+ P_HBW = 0.8 * df["workers"] + 0.2 * df["cars"]
157
+ P_HBE = 1.2 * df["students"]
158
+ P_HBS = 0.4 * df["households"]
159
+
160
+ productions = pd.DataFrame({
161
+ "HBW": P_HBW,
162
+ "HBE": P_HBE,
163
+ "HBS": P_HBS
164
+ }, index=df.index)
165
+
166
+ # Attractions (jobs, schools, retail)
167
+ A_HBW = 0.7 * df["service_jobs"] + 0.3 * df["industrial_jobs"]
168
+ A_HBE = 1.5 * df["school_capacity"]
169
+ A_HBS = 1.3 * df["retail_floor_area"]
170
+
171
+ attractions = pd.DataFrame({
172
+ "HBW": A_HBW,
173
+ "HBE": A_HBE,
174
+ "HBS": A_HBS
175
+ }, index=df.index)
176
+
177
+ # Balance productions and attractions for each purpose
178
+ for p in PURPOSES:
179
+ total_P = productions[p].sum()
180
+ total_A = attractions[p].sum()
181
+ if total_A <= 0:
182
+ continue
183
+ factor = total_P / total_A
184
+ attractions[p] *= factor
185
+
186
+ return productions, attractions
187
+
188
+ # -------------------------------------------------
189
+ # 3. GRAVITY-BASED TRIP DISTRIBUTION WITH IPF
190
+ # -------------------------------------------------
191
+
192
+ def gravity_impedance(travel_time_min: np.ndarray,
193
+ beta: float = 1.5) -> np.ndarray:
194
+ """
195
+ Simple impedance function f(c_ij) = c_ij^beta.
196
+
197
+ Smaller f => more attractive; will be inverted later.
198
+ """
199
+ c = np.maximum(travel_time_min, 1e-3)
200
+ return c ** beta
201
+
202
+
203
+ def gravity_distribution(productions: pd.Series,
204
+ attractions: pd.Series,
205
+ travel_time: pd.DataFrame,
206
+ beta: float = 1.5,
207
+ max_iter: int = 1000,
208
+ tol: float = 1e-4) -> pd.DataFrame:
209
+ """
210
+ Gravity model with iterative proportional fitting (IPF) to match
211
+ row and column totals.
212
+
213
+ Parameters
214
+ ----------
215
+ productions : Series
216
+ attractions : Series
217
+ travel_time : DataFrame
218
+ beta : float
219
+ max_iter : int
220
+ tol : float
221
+
222
+ Returns
223
+ -------
224
+ T : DataFrame (OD matrix)
225
+ """
226
+ zones = productions.index
227
+ c = travel_time.loc[zones, zones].to_numpy()
228
+ f = gravity_impedance(c, beta=beta)
229
+
230
+ P = productions.to_numpy()
231
+ A = attractions.to_numpy()
232
+
233
+ # Initial unbalanced matrix
234
+ W = np.outer(P, A) / f
235
+ W[W < 0] = 0.0
236
+
237
+ T = W.copy()
238
+ # IPF
239
+ for _ in range(max_iter):
240
+ # Row adjustment
241
+ row_sums = T.sum(axis=1)
242
+ row_factors = np.divide(P, row_sums,
243
+ out=np.ones_like(P),
244
+ where=row_sums > 0)
245
+ T = (T.T * row_factors).T
246
+
247
+ # Column adjustment
248
+ col_sums = T.sum(axis=0)
249
+ col_factors = np.divide(A, col_sums,
250
+ out=np.ones_like(A),
251
+ where=col_sums > 0)
252
+ T = T * col_factors
253
+
254
+ # Convergence check
255
+ row_err = np.abs(T.sum(axis=1) - P).sum()
256
+ col_err = np.abs(T.sum(axis=0) - A).sum()
257
+ if row_err < tol and col_err < tol:
258
+ break
259
+
260
+ T_df = pd.DataFrame(T, index=zones, columns=zones)
261
+ return T_df
262
+
263
+
264
+ def build_all_od_matrices(productions: pd.DataFrame,
265
+ attractions: pd.DataFrame,
266
+ travel_time: pd.DataFrame,
267
+ beta_by_purpose: Dict[str, float] | None = None
268
+ ) -> Dict[str, pd.DataFrame]:
269
+ """
270
+ Build OD matrices for each purpose.
271
+
272
+ Returns
273
+ -------
274
+ od_mats : dict[purpose -> DataFrame]
275
+ """
276
+ if beta_by_purpose is None:
277
+ beta_by_purpose = {"HBW": 1.5, "HBE": 1.6, "HBS": 1.4}
278
+
279
+ od_mats = {}
280
+ for p in PURPOSES:
281
+ od_mats[p] = gravity_distribution(
282
+ productions[p], attractions[p],
283
+ travel_time=travel_time,
284
+ beta=beta_by_purpose.get(p, 1.5),
285
+ )
286
+ return od_mats
287
+
288
+ # -------------------------------------------------
289
+ # 4. MODE CHOICE (MULTINOMIAL LOGIT)
290
+ # -------------------------------------------------
291
+
292
+ MODES = ["car", "metro", "bus"]
293
+
294
+
295
+ @dataclass
296
+ class ModeChoiceResult:
297
+ probabilities: Dict[str, pd.DataFrame] # mode -> P_ij
298
+ volumes: Dict[str, pd.DataFrame] # mode -> T_ij^mode
299
+ total_od: pd.DataFrame # aggregate OD (all purposes)
300
+
301
+
302
+ def synthetic_mode_choice_costs(travel_time_car: pd.DataFrame
303
+ ) -> Tuple[Dict[str, pd.DataFrame],
304
+ Dict[str, pd.DataFrame]]:
305
+ """
306
+ Given base car travel time, build synthetic time and cost matrices
307
+ for each mode.
308
+
309
+ Returns
310
+ -------
311
+ time_mats : dict[mode -> DataFrame]
312
+ cost_mats : dict[mode -> DataFrame]
313
+ """
314
+ tt_car = travel_time_car.copy()
315
+ zones = tt_car.index
316
+
317
+ # Metro is faster, bus is slower
318
+ tt_metro = tt_car * 0.8
319
+ tt_bus = tt_car * 1.3
320
+
321
+ # Costs (arbitrary synthetic)
322
+ dist_factor = tt_car / 60 * 30 # ~ distance proxy (km)
323
+ cost_car = 2 + 0.12 * dist_factor # fuel + parking etc.
324
+ cost_metro = 15 + 0.02 * dist_factor # base fare + distance
325
+ cost_bus = 8 + 0.03 * dist_factor
326
+
327
+ time_mats = {
328
+ "car": tt_car,
329
+ "metro": tt_metro,
330
+ "bus": tt_bus
331
+ }
332
+ cost_mats = {
333
+ "car": cost_car,
334
+ "metro": cost_metro,
335
+ "bus": cost_bus
336
+ }
337
+ return time_mats, cost_mats
338
+
339
+
340
+ def mode_choice(od_mats: Dict[str, pd.DataFrame],
341
+ taz: pd.DataFrame,
342
+ travel_time_car: pd.DataFrame,
343
+ beta_time: float = -0.06,
344
+ beta_cost: float = -0.03,
345
+ beta_car_own: float = 0.5
346
+ ) -> ModeChoiceResult:
347
+ """
348
+ Multinomial logit mode choice applied to aggregate OD flows
349
+ (sum over purposes).
350
+
351
+ Parameters
352
+ ----------
353
+ od_mats : dict[purpose -> OD matrix]
354
+ taz : DataFrame
355
+ travel_time_car : DataFrame
356
+
357
+ Returns
358
+ -------
359
+ ModeChoiceResult
360
+ """
361
+ zones = travel_time_car.index
362
+ # Aggregate OD across purposes
363
+ total_od = sum(od_mats.values())
364
+ total_od = total_od.loc[zones, zones]
365
+
366
+ time_mats, cost_mats = synthetic_mode_choice_costs(travel_time_car)
367
+
368
+ # Car ownership by origin
369
+ car_own = taz["car_ownership_rate"].reindex(zones).to_numpy()
370
+
371
+ n = len(zones)
372
+ car_own_matrix = np.repeat(car_own[:, None], n, axis=1)
373
+
374
+ utilities = {}
375
+ for mode in MODES:
376
+ tt = time_mats[mode].to_numpy()
377
+ cost = cost_mats[mode].to_numpy()
378
+
379
+ if mode == "car":
380
+ U = beta_time * tt + beta_cost * cost + beta_car_own * car_own_matrix
381
+ else:
382
+ U = beta_time * tt + beta_cost * cost
383
+ utilities[mode] = U
384
+
385
+ # Compute probabilities
386
+ exp_U_sum = np.zeros_like(next(iter(utilities.values())))
387
+ for U in utilities.values():
388
+ exp_U_sum += np.exp(U)
389
+
390
+ probabilities = {}
391
+ for mode, U in utilities.items():
392
+ P = np.exp(U) / np.maximum(exp_U_sum, 1e-12)
393
+ probabilities[mode] = pd.DataFrame(P, index=zones, columns=zones)
394
+
395
+ # Mode-specific flows
396
+ volumes = {}
397
+ total_od_np = total_od.to_numpy()
398
+ for mode in MODES:
399
+ volumes[mode] = pd.DataFrame(
400
+ total_od_np * probabilities[mode].to_numpy(),
401
+ index=zones, columns=zones
402
+ )
403
+
404
+ return ModeChoiceResult(
405
+ probabilities=probabilities,
406
+ volumes=volumes,
407
+ total_od=total_od
408
+ )
409
+
410
+ # -------------------------------------------------
411
+ # 5. SYNTHETIC NETWORK & AON ROUTE ASSIGNMENT
412
+ # -------------------------------------------------
413
+
414
+ @dataclass
415
+ class Network:
416
+ G: nx.DiGraph
417
+ link_df: pd.DataFrame # index: link id, columns: from, to, ff_time, capacity, distance
418
+ taz_to_node: Dict[int, int] # mapping from TAZ -> nearest node
419
+
420
+
421
+ def generate_synthetic_network(taz: pd.DataFrame,
422
+ avg_speed_kmh: float = 30.0,
423
+ seed: int = RANDOM_SEED) -> Network:
424
+ """
425
+ Build a synthetic directed network using TAZ centroids plus extra connectors.
426
+
427
+ Strategy:
428
+ - Use TAZ centroids as main nodes.
429
+ - Connect each node to its k nearest neighbours (k=3) both directions.
430
+
431
+ Returns
432
+ -------
433
+ Network
434
+ """
435
+ rng_local = np.random.default_rng(seed)
436
+ coords = taz[["x_km", "y_km"]].to_numpy()
437
+ zones = taz.index.to_list()
438
+ n = len(zones)
439
+
440
+ G = nx.DiGraph()
441
+ for i, z in enumerate(zones):
442
+ G.add_node(z, x=coords[i, 0], y=coords[i, 1])
443
+
444
+ # Connect to k nearest neighbours
445
+ k = 3
446
+ link_records = []
447
+ link_id = 0
448
+
449
+ for i, zi in enumerate(zones):
450
+ xi, yi = coords[i]
451
+ # distances to others
452
+ dx = coords[:, 0] - xi
453
+ dy = coords[:, 1] - yi
454
+ dist = np.sqrt(dx ** 2 + dy ** 2)
455
+ order = np.argsort(dist)
456
+ # take nearest k excluding itself
457
+ neighbours_idx = [j for j in order if j != i][:k]
458
+ for j in neighbours_idx:
459
+ zj = zones[j]
460
+ d_km = dist[j]
461
+ if d_km <= 0:
462
+ continue
463
+ ff_time = (d_km / avg_speed_kmh) * 60 # minutes
464
+ # capacity (veh/h) synthetic
465
+ cap = rng_local.integers(1200, 2400)
466
+
467
+ G.add_edge(zi, zj, length_km=d_km, ff_time=ff_time, capacity=cap)
468
+
469
+ link_records.append({
470
+ "link_id": link_id,
471
+ "from": zi,
472
+ "to": zj,
473
+ "distance_km": d_km,
474
+ "ff_time_min": ff_time,
475
+ "capacity_vehph": cap
476
+ })
477
+ link_id += 1
478
+
479
+ link_df = pd.DataFrame(link_records).set_index("link_id")
480
+
481
+ # Map each TAZ directly to its node (here they coincide)
482
+ taz_to_node = {int(z): int(z) for z in zones}
483
+
484
+ return Network(G=G, link_df=link_df, taz_to_node=taz_to_node)
485
+
486
+
487
+ def aon_assignment(od_matrix: pd.DataFrame,
488
+ network: Network) -> pd.DataFrame:
489
+ """
490
+ All-or-nothing assignment of OD matrix to network links
491
+ using free-flow travel time as cost.
492
+
493
+ Parameters
494
+ ----------
495
+ od_matrix : DataFrame (TAZ x TAZ)
496
+ network : Network
497
+
498
+ Returns
499
+ -------
500
+ link_flows : DataFrame (index=link_id, column='flow')
501
+ """
502
+ G = network.G
503
+ taz_to_node = network.taz_to_node
504
+ zones = od_matrix.index.to_list()
505
+ flows = np.zeros(len(network.link_df), dtype=float)
506
+
507
+ # Precompute a mapping from (u,v) to link_id
508
+ edge_to_link = {}
509
+ for lid, row in network.link_df.iterrows():
510
+ edge_to_link[(row["from"], row["to"])] = lid
511
+
512
+ # Use ff_time as edge weight
513
+ for (u, v, data) in G.edges(data=True):
514
+ if "ff_time" not in data:
515
+ data["ff_time"] = data.get("ff_time_min", 1.0)
516
+
517
+ # For each OD pair, find shortest path and add flow
518
+ for i, o in enumerate(zones):
519
+ origin_node = taz_to_node[int(o)]
520
+ for j, d in enumerate(zones):
521
+ if i == j:
522
+ continue
523
+ dest_node = taz_to_node[int(d)]
524
+ demand = od_matrix.iat[i, j]
525
+ if demand <= 0:
526
+ continue
527
+ try:
528
+ path = nx.shortest_path(G, origin_node, dest_node,
529
+ weight="ff_time")
530
+ except nx.NetworkXNoPath:
531
+ continue
532
+ # accumulate flow on each edge of path
533
+ for k in range(len(path) - 1):
534
+ u = path[k]
535
+ v = path[k + 1]
536
+ lid = edge_to_link.get((u, v))
537
+ if lid is not None:
538
+ flows[lid] += demand
539
+
540
+ link_flows = network.link_df.copy()
541
+ link_flows["flow_vehph"] = flows
542
+ return link_flows
543
+
544
+ # -------------------------------------------------
545
+ # 6. QUICK DEMO (RUN THIS FILE DIRECTLY)
546
+ # -------------------------------------------------
547
+
548
+ if __name__ == "__main__":
549
+ # 1. Generate synthetic city
550
+ city = generate_synthetic_city(num_zones=NUM_ZONES)
551
+ taz = city.taz
552
+ print("TAZ sample:\n", taz.head(), "\n")
553
+
554
+ # 2. Trip generation
555
+ productions, attractions = trip_generation(taz)
556
+ print("Total productions by purpose:\n", productions.sum(), "\n")
557
+ print("Total attractions by purpose:\n", attractions.sum(), "\n")
558
+
559
+ # 3. OD matrices by gravity
560
+ od_mats = build_all_od_matrices(productions, attractions,
561
+ travel_time=city.travel_time_matrix)
562
+ for p, od in od_mats.items():
563
+ print(f"OD matrix ({p}) total trips: {od.values.sum():.1f}")
564
+
565
+ # 4. Mode choice
566
+ mc_result = mode_choice(od_mats, taz, city.travel_time_matrix)
567
+ print("\nMode shares (total trips):")
568
+ total_trips = mc_result.total_od.values.sum()
569
+ for m in MODES:
570
+ trips_m = mc_result.volumes[m].values.sum()
571
+ print(f" {m}: {trips_m:.1f} ({100 * trips_m / total_trips:.1f} %)")
572
+
573
+ # 5. Network & AON assignment (using car OD only as example)
574
+ network = generate_synthetic_network(taz)
575
+ car_od = mc_result.volumes["car"]
576
+ link_flows = aon_assignment(car_od, network)
577
+ print("\nLink flows (first 10):\n", link_flows.head(10))
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit==1.31.1
2
+ pandas==2.1.4
3
+ numpy==1.26.4
4
+ scikit-learn==1.3.2
5
+ matplotlib==3.7.2
6
+ seaborn==0.12.2
7
+ shap==0.43.0
8
+ numba==0.58.1
9
+ tqdm==4.66.1
10
+ networkx==3.1