mahbubchula commited on
Commit
a58f1b7
·
verified ·
1 Parent(s): eb67502

Upload 8 files

Browse files
modules/__init__.py ADDED
File without changes
modules/ai_link_flow_emulator.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/ai_link_flow_emulator.py
2
+ # TripAI – AI Emulator for Link Flows
3
+
4
+ from __future__ import annotations
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Tuple
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.ensemble import RandomForestRegressor
11
+
12
+ from .route_assignment import aon_assignment, Network
13
+
14
+
15
+ @dataclass
16
+ class LinkFlowEmulator:
17
+ """
18
+ AI emulator for link flows under scaled OD demand.
19
+
20
+ Attributes
21
+ ----------
22
+ model : RandomForestRegressor
23
+ Multi-output regressor mapping demand scale -> link flows.
24
+ link_ids : np.ndarray
25
+ IDs of links in the same order as training outputs.
26
+ base_total_demand : float
27
+ Total baseline car OD (for reference).
28
+ """
29
+ model: RandomForestRegressor
30
+ link_ids: np.ndarray
31
+ base_total_demand: float
32
+
33
+
34
+ def _generate_training_scenarios(
35
+ base_od: np.ndarray,
36
+ network: Network,
37
+ n_scenarios: int = 20,
38
+ low_scale: float = 0.7,
39
+ high_scale: float = 1.3,
40
+ ) -> Tuple[np.ndarray, np.ndarray]:
41
+ """
42
+ Generate training scenarios by scaling baseline OD and performing
43
+ AON assignment to obtain link flows.
44
+
45
+ Parameters
46
+ ----------
47
+ base_od : np.ndarray
48
+ Baseline OD matrix (veh/h).
49
+ network : Network
50
+ n_scenarios : int
51
+ Number of random scaling scenarios.
52
+ low_scale : float
53
+ Minimum demand scale factor.
54
+ high_scale : float
55
+ Maximum demand scale factor.
56
+
57
+ Returns
58
+ -------
59
+ X : np.ndarray
60
+ Feature matrix of shape (n_scenarios, 1) – the demand scale.
61
+ Y : np.ndarray
62
+ Target matrix of shape (n_scenarios, n_links) – link flows.
63
+ """
64
+ n_zones = base_od.shape[0]
65
+ n_links = len(network.links)
66
+ scales = np.random.uniform(low_scale, high_scale, size=n_scenarios)
67
+
68
+ X = scales.reshape(-1, 1)
69
+ Y = np.zeros((n_scenarios, n_links), dtype=float)
70
+
71
+ # Build index -> (from_zone, to_zone) map to reuse AON logic
72
+ # We will call the existing aon_assignment with scaled OD each time.
73
+ # Convert base OD to DataFrame with synthetic zone index 0..n-1.
74
+ zones = np.arange(n_zones)
75
+ base_od_df = pd.DataFrame(base_od, index=zones, columns=zones)
76
+
77
+ for idx, s in enumerate(scales):
78
+ od_scaled = base_od_df * s
79
+ flows_df = aon_assignment(od_scaled, network)
80
+ Y[idx, :] = flows_df["flow_vehph"].to_numpy()
81
+
82
+ return X, Y
83
+
84
+
85
+ def train_link_flow_emulator(
86
+ base_car_od: np.ndarray,
87
+ network: Network,
88
+ n_scenarios: int = 20,
89
+ ) -> tuple[LinkFlowEmulator, pd.DataFrame]:
90
+ """
91
+ Train a simple RandomForest-based emulator that maps a single
92
+ scalar 'demand scale' to resulting link flows.
93
+
94
+ Parameters
95
+ ----------
96
+ base_car_od : np.ndarray
97
+ Baseline car OD matrix (veh/h).
98
+ network : Network
99
+ n_scenarios : int
100
+ Number of training scenarios.
101
+
102
+ Returns
103
+ -------
104
+ emulator : LinkFlowEmulator
105
+ training_history : pd.DataFrame
106
+ Scenario scales and corresponding total flows, for diagnostics.
107
+ """
108
+ X, Y = _generate_training_scenarios(base_car_od, network, n_scenarios=n_scenarios)
109
+
110
+ model = RandomForestRegressor(
111
+ n_estimators=200,
112
+ max_depth=12,
113
+ random_state=42,
114
+ )
115
+ model.fit(X, Y)
116
+
117
+ link_ids = network.links.index.to_numpy()
118
+ base_total = float(base_car_od.sum())
119
+
120
+ # Build training history for inspection
121
+ total_flows = Y.sum(axis=1)
122
+ training_history = pd.DataFrame(
123
+ {
124
+ "scale": X.flatten(),
125
+ "total_link_flow": total_flows,
126
+ }
127
+ )
128
+
129
+ emulator = LinkFlowEmulator(
130
+ model=model,
131
+ link_ids=link_ids,
132
+ base_total_demand=base_total,
133
+ )
134
+ return emulator, training_history
135
+
136
+
137
+ def predict_link_flows(
138
+ emulator: LinkFlowEmulator,
139
+ scale: float,
140
+ network: Network,
141
+ ) -> pd.DataFrame:
142
+ """
143
+ Predict link flows for a new demand scale using the trained emulator.
144
+
145
+ Parameters
146
+ ----------
147
+ emulator : LinkFlowEmulator
148
+ scale : float
149
+ Multiplicative scaling factor relative to baseline OD.
150
+ network : Network
151
+
152
+ Returns
153
+ -------
154
+ pd.DataFrame
155
+ Link table with predicted flows in column 'flow_vehph_emulated'.
156
+ """
157
+ X_new = np.array([[scale]], dtype=float)
158
+ y_pred = emulator.model.predict(X_new).flatten()
159
+
160
+ links = network.links.copy()
161
+ links["flow_vehph_emulated"] = y_pred
162
+ return links
modules/gravity_model.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/gravity_model.py
2
+ # TripAI – Gravity Model + OD Matrix Builder
3
+
4
+ from __future__ import annotations
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from .utils import iterative_proportional_fitting
9
+
10
+ PURPOSES = ["HBW", "HBE", "HBS"]
11
+
12
+
13
+ def gravity_model_doubly_constrained(
14
+ productions: pd.Series,
15
+ attractions: pd.Series,
16
+ travel_time: pd.DataFrame,
17
+ beta: float = -0.1,
18
+ max_iter: int = 50,
19
+ tol: float = 1e-6,
20
+ ) -> pd.DataFrame:
21
+ """
22
+ Doubly-constrained gravity model with IPF.
23
+
24
+ T_ij ∝ P_i * A_j * f(c_ij),
25
+ where f(c_ij) = exp(beta * c_ij), beta < 0
26
+
27
+ IPF is used to ensure row sums match productions and column sums
28
+ match attractions.
29
+
30
+ Parameters
31
+ ----------
32
+ productions : pd.Series
33
+ Trip productions P_i by origin TAZ.
34
+ attractions : pd.Series
35
+ Trip attractions A_j by destination TAZ.
36
+ travel_time : pd.DataFrame
37
+ Impedance matrix c_ij (minutes).
38
+ beta : float
39
+ Distance-decay parameter (negative).
40
+ max_iter : int
41
+ Maximum IPF iterations.
42
+ tol : float
43
+ Tolerance for marginal convergence.
44
+
45
+ Returns
46
+ -------
47
+ pd.DataFrame
48
+ OD matrix T_ij (index=origins, columns=destinations).
49
+ """
50
+ idx = productions.index
51
+ P = productions.values.astype(float)
52
+ A = attractions.values.astype(float)
53
+
54
+ c = travel_time.loc[idx, idx].values.astype(float)
55
+ # Impedance function
56
+ F = np.exp(beta * c)
57
+
58
+ # Initial gravity estimate
59
+ T0 = np.outer(P, A) * F
60
+ # Avoid all-zero rows/cols
61
+ T0[T0 < 0] = 0.0
62
+
63
+ T_adj = iterative_proportional_fitting(T0, P, A, max_iter=max_iter, tol=tol)
64
+
65
+ return pd.DataFrame(T_adj, index=idx, columns=idx)
66
+
67
+
68
+ def build_all_od_matrices(
69
+ productions_df: pd.DataFrame,
70
+ attractions_df: pd.DataFrame,
71
+ travel_time: pd.DataFrame,
72
+ beta: float = -0.1,
73
+ max_iter: int = 50,
74
+ tol: float = 1e-6,
75
+ ) -> dict[str, pd.DataFrame]:
76
+ """
77
+ Build OD matrices for all purposes using the doubly-constrained gravity model.
78
+
79
+ Parameters
80
+ ----------
81
+ productions_df : pd.DataFrame
82
+ Columns = purposes (HBW, HBE, HBS), index = TAZ.
83
+ attractions_df : pd.DataFrame
84
+ Columns = purposes (HBW, HBE, HBS), index = TAZ.
85
+ travel_time : pd.DataFrame
86
+ Travel time matrix (minutes), index/cols = TAZ.
87
+ beta : float
88
+ Distance-decay parameter for all purposes.
89
+ max_iter : int
90
+ Maximum iterations for IPF.
91
+ tol : float
92
+ Convergence tolerance.
93
+
94
+ Returns
95
+ -------
96
+ dict[str, pd.DataFrame]
97
+ Mapping from purpose -> OD matrix (DataFrame).
98
+ """
99
+ od_mats: dict[str, pd.DataFrame] = {}
100
+
101
+ for purpose in PURPOSES:
102
+ P = productions_df[purpose]
103
+ A = attractions_df[purpose]
104
+ od_mats[purpose] = gravity_model_doubly_constrained(
105
+ P, A, travel_time, beta=beta, max_iter=max_iter, tol=tol
106
+ )
107
+
108
+ return od_mats
modules/mode_choice.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/mode_choice.py
2
+ # TripAI – Multinomial Logit Mode Choice
3
+
4
+ from __future__ import annotations
5
+ from dataclasses import dataclass
6
+ from typing import Dict
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass
13
+ class ModeChoiceResult:
14
+ """
15
+ Container for mode choice outputs.
16
+
17
+ Attributes
18
+ ----------
19
+ total_od : pd.DataFrame
20
+ OD matrix summed over all purposes.
21
+ volumes : Dict[str, pd.DataFrame]
22
+ Mode-specific OD matrices (Car/Metro/Bus).
23
+ probabilities : Dict[str, pd.DataFrame]
24
+ Mode choice probabilities per OD pair.
25
+ """
26
+ total_od: pd.DataFrame
27
+ volumes: Dict[str, pd.DataFrame]
28
+ probabilities: Dict[str, pd.DataFrame]
29
+
30
+
31
+ def mode_choice(
32
+ od_matrices: Dict[str, pd.DataFrame],
33
+ taz: pd.DataFrame,
34
+ travel_time: pd.DataFrame,
35
+ beta_time: float = -0.06,
36
+ beta_cost: float = -0.03,
37
+ beta_car_own: float = 0.5,
38
+ ) -> ModeChoiceResult:
39
+ """
40
+ Apply a simple Multinomial Logit (MNL) mode choice model for
41
+ Car / Metro / Bus.
42
+
43
+ U_m = β_time * time_m + β_cost * cost_m + γ * car_ownership (for car only)
44
+
45
+ Parameters
46
+ ----------
47
+ od_matrices : dict[str, pd.DataFrame]
48
+ OD matrices by purpose (HBW, HBE, HBS).
49
+ taz : pd.DataFrame
50
+ TAZ attributes with 'car_ownership_rate', 'x_km', 'y_km'.
51
+ travel_time : pd.DataFrame
52
+ Base car travel time matrix (minutes).
53
+ beta_time : float
54
+ Coefficient on in-vehicle travel time.
55
+ beta_cost : float
56
+ Coefficient on generalized cost.
57
+ beta_car_own : float
58
+ Additional utility for Car associated with car ownership rate.
59
+
60
+ Returns
61
+ -------
62
+ ModeChoiceResult
63
+ Aggregated OD, volumes by mode, and probabilities by mode.
64
+ """
65
+ zones = travel_time.index
66
+
67
+ # 1. Aggregate OD across purposes
68
+ total_od = None
69
+ for mat in od_matrices.values():
70
+ if total_od is None:
71
+ total_od = mat.copy()
72
+ else:
73
+ total_od += mat
74
+ total_od = total_od.loc[zones, zones]
75
+
76
+ # 2. Build time and cost matrices for each mode
77
+ tt_car = travel_time.loc[zones, zones].astype(float)
78
+ tt_metro = tt_car * 0.8 # metro faster
79
+ tt_bus = tt_car * 1.3 # bus slower
80
+
81
+ # Distance proxy (km)
82
+ dist_proxy = tt_car / 60.0 * 30.0 # 30 km/h
83
+
84
+ cost_car = 2.0 + 0.12 * dist_proxy
85
+ cost_metro = 15.0
86
+ cost_bus = 8.0 + 0.03 * dist_proxy
87
+
88
+ # 3. Car ownership matrix
89
+ car_own = taz["car_ownership_rate"].reindex(zones).to_numpy()
90
+ n = len(zones)
91
+ car_own_matrix = np.repeat(car_own[:, None], n, axis=1)
92
+
93
+ # 4. Utilities
94
+ modes = ["car", "metro", "bus"]
95
+ utilities = {}
96
+
97
+ # Car
98
+ U_car = (
99
+ beta_time * tt_car.to_numpy()
100
+ + beta_cost * cost_car.to_numpy()
101
+ + beta_car_own * car_own_matrix
102
+ )
103
+ utilities["car"] = U_car
104
+
105
+ # Metro
106
+ U_metro = beta_time * tt_metro.to_numpy() + beta_cost * cost_metro.to_numpy()
107
+ utilities["metro"] = U_metro
108
+
109
+ # Bus
110
+ U_bus = beta_time * tt_bus.to_numpy() + beta_cost * cost_bus.to_numpy()
111
+ utilities["bus"] = U_bus
112
+
113
+ # 5. Probabilities via softmax
114
+ exp_sum = np.zeros_like(U_car)
115
+ for U in utilities.values():
116
+ exp_sum += np.exp(U)
117
+
118
+ probabilities: Dict[str, pd.DataFrame] = {}
119
+ for mode, U in utilities.items():
120
+ P = np.exp(U) / np.maximum(exp_sum, 1e-12)
121
+ probabilities[mode] = pd.DataFrame(P, index=zones, columns=zones)
122
+
123
+ # 6. Mode-specific OD flows
124
+ volumes: Dict[str, pd.DataFrame] = {}
125
+ total_np = total_od.to_numpy()
126
+ for mode in modes:
127
+ volumes[mode] = pd.DataFrame(
128
+ total_np * probabilities[mode].to_numpy(),
129
+ index=zones,
130
+ columns=zones,
131
+ )
132
+
133
+ return ModeChoiceResult(
134
+ total_od=total_od,
135
+ volumes=volumes,
136
+ probabilities=probabilities,
137
+ )
modules/route_assignment.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/route_assignment.py
2
+ # TripAI – Synthetic Network + AON + Frank–Wolfe UE
3
+
4
+ from __future__ import annotations
5
+ from dataclasses import dataclass
6
+ from typing import List
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass
13
+ class Network:
14
+ """
15
+ Simple synthetic network representation where each TAZ pair
16
+ (i, j) is connected by a single directed link.
17
+
18
+ Attributes
19
+ ----------
20
+ links : pd.DataFrame
21
+ Columns:
22
+ - link_id
23
+ - from_zone
24
+ - to_zone
25
+ - length_km
26
+ - t0_min (free-flow travel time)
27
+ - capacity_vehph
28
+ - alpha (BPR parameter)
29
+ - beta (BPR parameter)
30
+ """
31
+ links: pd.DataFrame
32
+
33
+
34
+ def generate_synthetic_network(taz: pd.DataFrame) -> Network:
35
+ """
36
+ Generate a fully connected directed network over TAZ centroids.
37
+ Each ordered pair (i, j), i != j, is represented as a distinct link.
38
+
39
+ Travel times are approximated from Euclidean distance and an
40
+ assumed free-flow speed.
41
+
42
+ Parameters
43
+ ----------
44
+ taz : pd.DataFrame
45
+ Must include 'x_km' and 'y_km' columns.
46
+
47
+ Returns
48
+ -------
49
+ Network
50
+ """
51
+ zones = taz.index.to_list()
52
+ coords = taz[["x_km", "y_km"]].to_numpy()
53
+ n = len(zones)
54
+
55
+ rows = []
56
+ link_id = 0
57
+ ff_speed_kmh = 30.0
58
+
59
+ for i_idx, i in enumerate(zones):
60
+ for j_idx, j in enumerate(zones):
61
+ if i == j:
62
+ continue
63
+ dx = coords[j_idx, 0] - coords[i_idx, 0]
64
+ dy = coords[j_idx, 1] - coords[i_idx, 1]
65
+ dist = np.sqrt(dx**2 + dy**2) # km
66
+ t0 = (dist / max(ff_speed_kmh, 1e-3)) * 60.0 + 3.0 # minutes
67
+
68
+ rows.append(
69
+ {
70
+ "link_id": link_id,
71
+ "from_zone": i,
72
+ "to_zone": j,
73
+ "length_km": dist,
74
+ "t0_min": t0,
75
+ "capacity_vehph": np.random.uniform(1500, 2500),
76
+ "alpha": 0.15,
77
+ "beta": 4.0,
78
+ }
79
+ )
80
+ link_id += 1
81
+
82
+ links_df = pd.DataFrame(rows).set_index("link_id")
83
+ return Network(links=links_df)
84
+
85
+
86
+ def _init_flow_column(links: pd.DataFrame, col: str = "flow_vehph") -> pd.DataFrame:
87
+ df = links.copy()
88
+ if col not in df.columns:
89
+ df[col] = 0.0
90
+ else:
91
+ df[col] = 0.0
92
+ return df
93
+
94
+
95
+ def aon_assignment(od_car: pd.DataFrame, network: Network) -> pd.DataFrame:
96
+ """
97
+ All-or-nothing (AON) assignment assuming a single direct link
98
+ between each TAZ pair (i, j). All demand from i to j is loaded
99
+ on that link.
100
+
101
+ Parameters
102
+ ----------
103
+ od_car : pd.DataFrame
104
+ Car OD matrix (veh/h equivalent).
105
+ network : Network
106
+
107
+ Returns
108
+ -------
109
+ pd.DataFrame
110
+ Link flows with column 'flow_vehph'.
111
+ """
112
+ links = _init_flow_column(network.links, col="flow_vehph")
113
+ zones = od_car.index.to_list()
114
+
115
+ for i in zones:
116
+ for j in zones:
117
+ if i == j:
118
+ continue
119
+ q = float(od_car.loc[i, j])
120
+ if q <= 0:
121
+ continue
122
+ mask = (links["from_zone"] == i) & (links["to_zone"] == j)
123
+ links.loc[mask, "flow_vehph"] += q
124
+
125
+ return links
126
+
127
+
128
+ def _bpr_travel_time(
129
+ flows: np.ndarray,
130
+ t0: np.ndarray,
131
+ capacity: np.ndarray,
132
+ alpha: np.ndarray,
133
+ beta: np.ndarray,
134
+ ) -> np.ndarray:
135
+ """Standard BPR volume-delay function."""
136
+ vc = np.divide(flows, capacity, out=np.zeros_like(flows), where=capacity > 0)
137
+ return t0 * (1.0 + alpha * np.power(vc, beta))
138
+
139
+
140
+ def frank_wolfe_ue(
141
+ od_car: pd.DataFrame,
142
+ network: Network,
143
+ max_iter: int = 30,
144
+ ) -> pd.DataFrame:
145
+ """
146
+ Very simple Frank–Wolfe style User Equilibrium assignment over
147
+ the synthetic network where each OD pair has a single link.
148
+
149
+ Because there is only one 'route' per OD, the UE solution
150
+ coincides with the AON solution. This implementation still
151
+ outlines the iterative structure for pedagogical purposes.
152
+
153
+ Parameters
154
+ ----------
155
+ od_car : pd.DataFrame
156
+ Car OD matrix (veh/h).
157
+ network : Network
158
+ max_iter : int
159
+ Maximum iterations (for demonstration).
160
+
161
+ Returns
162
+ -------
163
+ pd.DataFrame
164
+ Link flows with column 'flow_vehph' and implied travel times.
165
+ """
166
+ links = network.links.copy()
167
+ n_links = len(links)
168
+
169
+ # Initialize flows
170
+ flows = np.zeros(n_links, dtype=float)
171
+
172
+ # Extract BPR parameters
173
+ t0 = links["t0_min"].to_numpy()
174
+ cap = links["capacity_vehph"].to_numpy()
175
+ alpha = links["alpha"].to_numpy()
176
+ beta = links["beta"].to_numpy()
177
+
178
+ # Pre-build a mapping (from_zone, to_zone) -> link indices
179
+ index = links.reset_index()
180
+ zone_pairs = {}
181
+ for idx, row in index.iterrows():
182
+ key = (row["from_zone"], row["to_zone"])
183
+ zone_pairs[key] = row["link_id"]
184
+
185
+ # Iterate Frank–Wolfe (though it converges immediately in this simple network)
186
+ for k in range(max_iter):
187
+ # Step 1: Compute travel times (not used for path choice here)
188
+ tt = _bpr_travel_time(flows, t0, cap, alpha, beta)
189
+
190
+ # Step 2: AON step (all or nothing given current times – here trivial)
191
+ aon_flows = np.zeros_like(flows)
192
+ zones = od_car.index.to_list()
193
+ for i in zones:
194
+ for j in zones:
195
+ if i == j:
196
+ continue
197
+ q = float(od_car.loc[i, j])
198
+ if q <= 0:
199
+ continue
200
+ lid = zone_pairs[(i, j)]
201
+ aon_flows[lid] += q
202
+
203
+ # Step 3: Line search step-size (generic diminishing rule)
204
+ step = 2.0 / (k + 2.0)
205
+ new_flows = flows + step * (aon_flows - flows)
206
+
207
+ # Convergence check
208
+ if np.allclose(new_flows, flows, atol=1e-3):
209
+ flows = new_flows
210
+ break
211
+
212
+ flows = new_flows
213
+
214
+ links["flow_vehph"] = flows
215
+ links["tt_min"] = _bpr_travel_time(flows, t0, cap, alpha, beta)
216
+ return links
modules/synthetic_city.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import os
4
+
5
+ from modules.synthetic_city import generate_synthetic_city
6
+
7
+ st.title("📊 Generate Synthetic City (20 TAZ)")
8
+
9
+ # -------------------------------------
10
+ # GENERATE SYNTHETIC CITY
11
+ # -------------------------------------
12
+ if st.button("Generate Synthetic Region"):
13
+ city = generate_synthetic_city()
14
+
15
+ # Save to session state
16
+ st.session_state["city"] = city
17
+ st.success("Synthetic city generated successfully!")
18
+
19
+ # -------------------------------------
20
+ # DISPLAY RESULTS
21
+ # -------------------------------------
22
+ if "city" in st.session_state:
23
+ city = st.session_state["city"]
24
+
25
+ st.subheader("TAZ Attributes")
26
+ st.dataframe(city.taz)
27
+
28
+ st.subheader("Summary Statistics")
29
+ st.write(city.taz.describe())
30
+
31
+ # Save files to /data/
32
+ os.makedirs("data", exist_ok=True)
33
+ city.taz.to_csv("data/taz_attributes.csv")
34
+ city.distance_matrix.to_csv("data/distance_matrix.csv")
35
+ city.travel_time_matrix.to_csv("data/travel_time_matrix.csv")
36
+
37
+ st.info("Files saved in /data/")
modules/trip_generation.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/trip_generation.py
2
+ # TripAI – Trip Generation Model
3
+
4
+ from __future__ import annotations
5
+ import pandas as pd
6
+ import numpy as np
7
+
8
+ PURPOSES = ["HBW", "HBE", "HBS"]
9
+
10
+
11
+ def trip_generation(taz: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
12
+ """
13
+ Compute trip productions and attractions for each TAZ for three purposes:
14
+ - HBW: Home–Based Work
15
+ - HBE: Home–Based Education
16
+ - HBS: Home–Based Shopping/Other
17
+
18
+ The functional forms are deliberately simple but grounded in standard
19
+ trip-rate logic and can be modified for calibration.
20
+
21
+ Parameters
22
+ ----------
23
+ taz : pd.DataFrame
24
+ TAZ-level attributes with at least the following columns:
25
+ ['households', 'workers', 'students', 'cars',
26
+ 'service_jobs', 'industrial_jobs', 'retail_jobs',
27
+ 'school_capacity', 'retail_floor_area'].
28
+
29
+ Returns
30
+ -------
31
+ productions : pd.DataFrame
32
+ Index = TAZ, columns = ['HBW', 'HBE', 'HBS'].
33
+ attractions : pd.DataFrame
34
+ Index = TAZ, columns = ['HBW', 'HBE', 'HBS'], balanced so that
35
+ sum(P) = sum(A) for each purpose.
36
+ """
37
+ df = taz.copy()
38
+
39
+ # ------------------------------------------------
40
+ # PRODUCTIONS (simple rate-based formulations)
41
+ # ------------------------------------------------
42
+ # HBW: mainly driven by workers and car availability
43
+ P_HBW = 0.8 * df["workers"] + 0.2 * df["cars"]
44
+
45
+ # HBE: driven by students
46
+ P_HBE = 1.2 * df["students"]
47
+
48
+ # HBS: driven by households (shopping, other)
49
+ P_HBS = 0.4 * df["households"]
50
+
51
+ productions = pd.DataFrame(
52
+ {"HBW": P_HBW, "HBE": P_HBE, "HBS": P_HBS},
53
+ index=df.index,
54
+ )
55
+
56
+ # ------------------------------------------------
57
+ # ATTRACTIONS (jobs, schools, retail)
58
+ # ------------------------------------------------
59
+ A_HBW = 0.7 * df["service_jobs"] + 0.3 * df["industrial_jobs"]
60
+ A_HBE = 1.5 * df["school_capacity"]
61
+ A_HBS = 1.3 * df["retail_floor_area"]
62
+
63
+ attractions = pd.DataFrame(
64
+ {"HBW": A_HBW, "HBE": A_HBE, "HBS": A_HBS},
65
+ index=df.index,
66
+ )
67
+
68
+ # ------------------------------------------------
69
+ # SIMPLE BALANCING (one-step scaling)
70
+ # ------------------------------------------------
71
+ for p in PURPOSES:
72
+ total_P = productions[p].sum()
73
+ total_A = attractions[p].sum()
74
+ if total_A > 0:
75
+ attractions[p] *= total_P / total_A
76
+
77
+ return productions, attractions
modules/utils.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # modules/utils.py
2
+ # TripAI – Utility functions (IPF, etc.)
3
+
4
+ from __future__ import annotations
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def iterative_proportional_fitting(
10
+ T_init: np.ndarray,
11
+ row_targets: np.ndarray,
12
+ col_targets: np.ndarray,
13
+ max_iter: int = 50,
14
+ tol: float = 1e-6,
15
+ ) -> np.ndarray:
16
+ """
17
+ Perform Iterative Proportional Fitting (IPF) to adjust an initial
18
+ non-negative matrix T_init so that its row and column sums match
19
+ given marginals.
20
+
21
+ Parameters
22
+ ----------
23
+ T_init : np.ndarray
24
+ Initial non-negative matrix (NxN).
25
+ row_targets : np.ndarray
26
+ Target row sums (length N).
27
+ col_targets : np.ndarray
28
+ Target column sums (length N).
29
+ max_iter : int
30
+ Maximum number of IPF iterations.
31
+ tol : float
32
+ Convergence tolerance on row/column marginal differences.
33
+
34
+ Returns
35
+ -------
36
+ np.ndarray
37
+ Adjusted matrix with approximately matching row/column totals.
38
+ """
39
+ T = T_init.copy().astype(float)
40
+ n = T.shape[0]
41
+
42
+ for _ in range(max_iter):
43
+ # Row scaling
44
+ row_sums = T.sum(axis=1)
45
+ row_factors = np.ones(n)
46
+ mask = row_sums > 0
47
+ row_factors[mask] = row_targets[mask] / row_sums[mask]
48
+ T *= row_factors[:, None]
49
+
50
+ # Column scaling
51
+ col_sums = T.sum(axis=0)
52
+ col_factors = np.ones(n)
53
+ mask = col_sums > 0
54
+ col_factors[mask] = col_targets[mask] / col_sums[mask]
55
+ T *= col_factors[None, :]
56
+
57
+ # Check convergence
58
+ if (
59
+ np.allclose(T.sum(axis=1), row_targets, atol=tol)
60
+ and np.allclose(T.sum(axis=0), col_targets, atol=tol)
61
+ ):
62
+ break
63
+
64
+ return T