itaykadosh commited on
Commit
c9ae9d6
Β·
verified Β·
1 Parent(s): 3a61277

Upload app/optimizer.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app/optimizer.py +263 -0
app/optimizer.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sequence Optimizer — Hungarian algorithm for minimum-risk A→DFW→B assignment.
3
+
4
+ Given a pool of DFW arrivals (airport A, arrival time) and departures (airport B,
5
+ departure time), finds the one-to-one assignment that minimizes total weather risk.
6
+
7
+ Uses scipy.optimize.linear_sum_assignment (Jonker-Volgenant algorithm, O(nΒ³)).
8
+ """
9
+ from __future__ import annotations
10
+ import numpy as np
11
+ import pandas as pd
12
+ from scipy.optimize import linear_sum_assignment
13
+
14
+ MIN_TURN = 30 # min turnaround minutes
15
+ MAX_TURN = 240 # max turnaround minutes
16
+ INFEASIBLE = 2.0 # penalty > max risk (1.0), forces infeasible pairs out
17
+ HIGH_THRESHOLD = 0.30 # calibrated score thresholds
18
+ MOD_THRESHOLD = 0.20
19
+
20
+
21
+ # ── Cost matrix ───────────────────────────────────────────────────────────────
22
+
23
+ def build_cost_matrix(
24
+ arrivals: pd.DataFrame, # cols: airport, time_min, flight, time_str[, carrier]
25
+ departures: pd.DataFrame, # cols: airport, time_min, flight, time_str[, carrier]
26
+ scores_idx: pd.DataFrame, # pair_risk_scores indexed by (airport_A, airport_B, Month)
27
+ month: int,
28
+ unknown_risk: float = 0.20, # neutral score for unknown pairs (calibrated scale)
29
+ ) -> np.ndarray:
30
+ """
31
+ Return n_arrivals Γ— n_departures cost matrix.
32
+ Vectorized: cross-join β†’ filter β†’ batch-lookup via merge. O(n*m) pandas, not Python loops.
33
+ Cross-carrier pairings (e.g. AA arrival β†’ DL departure) are blocked via INFEASIBLE penalty.
34
+ """
35
+ has_carrier = "carrier" in arrivals.columns and "carrier" in departures.columns
36
+
37
+ arr_cols = ["airport", "time_min"] + (["carrier"] if has_carrier else [])
38
+ dep_cols = ["airport", "time_min"] + (["carrier"] if has_carrier else [])
39
+ arr = arrivals.reset_index(drop=True)[arr_cols].copy()
40
+ dep = departures.reset_index(drop=True)[dep_cols].copy()
41
+ arr["_i"] = arr.index
42
+ dep["_j"] = dep.index
43
+
44
+ # Cross-join
45
+ pairs = arr.merge(dep, how="cross", suffixes=("_a", "_b"))
46
+
47
+ # Filter: turnaround window + no same-airport round-trip + same carrier
48
+ ta = pairs["time_min_b"] - pairs["time_min_a"]
49
+ mask = (ta >= MIN_TURN) & (ta <= MAX_TURN) & (pairs["airport_a"] != pairs["airport_b"])
50
+ if has_carrier:
51
+ mask &= (pairs["carrier_a"] == pairs["carrier_b"])
52
+ pairs = pairs[mask].copy()
53
+ pairs.rename(columns={"airport_a": "airport_A", "airport_b": "airport_B"}, inplace=True)
54
+ pairs["Month"] = month
55
+
56
+ # Batch risk lookup via merge against scores
57
+ scores_flat = scores_idx.reset_index()[["airport_A", "airport_B", "Month", "avg_risk_score"]]
58
+ pairs = pairs.merge(scores_flat, on=["airport_A", "airport_B", "Month"], how="left")
59
+ pairs["avg_risk_score"] = pairs["avg_risk_score"].fillna(unknown_risk)
60
+
61
+ # Fill cost matrix
62
+ n, m = len(arrivals), len(departures)
63
+ cost = np.full((n, m), INFEASIBLE, dtype=float)
64
+ cost[pairs["_i"].values, pairs["_j"].values] = pairs["avg_risk_score"].values
65
+ return cost
66
+
67
+
68
+ # ── Optimizer ─────────────────────────────────────────────────────────────────
69
+
70
+ def optimize_sequences(
71
+ arrivals: pd.DataFrame,
72
+ departures: pd.DataFrame,
73
+ scores_idx: pd.DataFrame,
74
+ month: int,
75
+ ) -> tuple[pd.DataFrame, dict]:
76
+ """
77
+ Run Hungarian algorithm β†’ minimum-risk assignment.
78
+
79
+ Returns:
80
+ (sequences_df, stats_dict)
81
+ """
82
+ if arrivals.empty or departures.empty:
83
+ return pd.DataFrame(), {"error": "No arrivals or departures in window"}
84
+
85
+ cost = build_cost_matrix(arrivals, departures, scores_idx, month)
86
+ row_ind, col_ind = linear_sum_assignment(cost)
87
+
88
+ results = []
89
+ for i, j in zip(row_ind, col_ind):
90
+ c = cost[i][j]
91
+ if c >= INFEASIBLE:
92
+ continue
93
+ arr = arrivals.iloc[i]
94
+ dep = departures.iloc[j]
95
+ ta = int(dep["time_min"] - arr["time_min"])
96
+ results.append({
97
+ "Sequence": f"{arr['airport']} β†’ DFW β†’ {dep['airport']}",
98
+ "airport_A": arr["airport"],
99
+ "airport_B": dep["airport"],
100
+ "flight_in": arr.get("flight", "β€”"),
101
+ "arr_time": arr.get("time_str", ""),
102
+ "flight_out": dep.get("flight", "β€”"),
103
+ "dep_time": dep.get("time_str", ""),
104
+ "turnaround_min": ta,
105
+ "risk_score": c,
106
+ "risk_label": "HIGH" if c >= HIGH_THRESHOLD else "MODERATE" if c >= MOD_THRESHOLD else "LOW",
107
+ })
108
+
109
+ df = pd.DataFrame(results).sort_values("risk_score", ascending=False).reset_index(drop=True)
110
+
111
+ # Worst-case benchmark: greedy highest-risk assignment (for comparison)
112
+ worst_cost = _worst_case_risk(cost, row_ind, col_ind)
113
+
114
+ feasible_costs = cost[row_ind, col_ind]
115
+ feasible_mask = feasible_costs < INFEASIBLE
116
+
117
+ stats = {
118
+ "n_arrivals": len(arrivals),
119
+ "n_departures": len(departures),
120
+ "n_matched": int(feasible_mask.sum()),
121
+ "feasible_pairs": int((cost < INFEASIBLE).sum()),
122
+ "optimal_total": float(feasible_costs[feasible_mask].sum()),
123
+ "optimal_avg": float(feasible_costs[feasible_mask].mean()) if feasible_mask.any() else 0.0,
124
+ "worst_total": worst_cost,
125
+ "risk_saved": max(0.0, worst_cost - float(feasible_costs[feasible_mask].sum())),
126
+ "pct_high": float((feasible_costs[feasible_mask] >= HIGH_THRESHOLD).mean()) if feasible_mask.any() else 0.0,
127
+ "cost_matrix": cost,
128
+ "row_ind": row_ind,
129
+ "col_ind": col_ind,
130
+ }
131
+ return df, stats
132
+
133
+
134
+ def _worst_case_risk(cost: np.ndarray, row_ind: np.ndarray, col_ind: np.ndarray) -> float:
135
+ """Approximate worst-case by flipping the cost matrix (maximize = minimize negative)."""
136
+ feasible = cost < INFEASIBLE
137
+ if not feasible.any():
138
+ return 0.0
139
+ neg_cost = np.where(feasible, 1.0 - cost, INFEASIBLE)
140
+ try:
141
+ wr, wc = linear_sum_assignment(neg_cost)
142
+ wc_vals = cost[wr, wc]
143
+ return float(wc_vals[wc_vals < INFEASIBLE].sum())
144
+ except Exception:
145
+ return float(cost[row_ind, col_ind][cost[row_ind, col_ind] < INFEASIBLE].sum())
146
+
147
+
148
+ # ── Schedule builders ─────────────────────────────────────────────────────────
149
+
150
+ def bts_to_arrivals(day_df: pd.DataFrame, arr_start_h: int, arr_end_h: int) -> pd.DataFrame:
151
+ """Extract arrivals β†’ DFW from BTS day DataFrame, filtered to hour window."""
152
+ df = day_df[day_df["Dest"] == "DFW"].copy()
153
+ df["time_min"] = (df["CRSArrTime"] // 100) * 60 + (df["CRSArrTime"] % 100)
154
+ df = df[(df["time_min"] >= arr_start_h * 60) & (df["time_min"] < arr_end_h * 60)]
155
+ df["time_str"] = (df["time_min"] // 60).astype(int).astype(str).str.zfill(2) + ":" + \
156
+ (df["time_min"] % 60).astype(int).astype(str).str.zfill(2)
157
+ df["carrier"] = df.get("Reporting_Airline", "AA").fillna("AA").astype(str)
158
+ df["flight"] = df["carrier"] + df["Flight_Number_Reporting_Airline"].fillna("").astype(str)
159
+ return df.rename(columns={"Origin": "airport"})[
160
+ ["airport", "time_min", "time_str", "flight", "Tail_Number", "carrier"]
161
+ ].dropna(subset=["airport", "time_min"]).reset_index(drop=True)
162
+
163
+
164
+ def bts_to_departures(day_df: pd.DataFrame, dep_start_h: int, dep_end_h: int) -> pd.DataFrame:
165
+ """Extract departures from DFW from BTS day DataFrame, filtered to hour window."""
166
+ df = day_df[day_df["Origin"] == "DFW"].copy()
167
+ df["time_min"] = (df["CRSDepTime"] // 100) * 60 + (df["CRSDepTime"] % 100)
168
+ df = df[(df["time_min"] >= dep_start_h * 60) & (df["time_min"] < dep_end_h * 60)]
169
+ df["time_str"] = (df["time_min"] // 60).astype(int).astype(str).str.zfill(2) + ":" + \
170
+ (df["time_min"] % 60).astype(int).astype(str).str.zfill(2)
171
+ df["carrier"] = df.get("Reporting_Airline", "AA").fillna("AA").astype(str)
172
+ df["flight"] = df["carrier"] + df["Flight_Number_Reporting_Airline"].fillna("").astype(str)
173
+ return df.rename(columns={"Dest": "airport"})[
174
+ ["airport", "time_min", "time_str", "flight", "Tail_Number", "carrier"]
175
+ ].dropna(subset=["airport", "time_min"]).reset_index(drop=True)
176
+
177
+
178
+ _DFW_TZ_OFFSET_H = -5 # DFW = CDT (UTC-5) Apr–Oct, CST (UTC-6) Nov–Mar
179
+ try:
180
+ import pytz as _pytz
181
+ _DFW_PYTZ = _pytz.timezone("America/Chicago")
182
+ except ImportError:
183
+ _DFW_PYTZ = None
184
+
185
+
186
+ def _to_dfw_local(dt) -> tuple[int, str]:
187
+ """Convert UTC datetime β†’ DFW local (minutes-from-midnight, display string)."""
188
+ if _DFW_PYTZ is not None:
189
+ dt_local = dt.astimezone(_DFW_PYTZ)
190
+ else:
191
+ from datetime import timedelta, timezone
192
+ offset = _DFW_TZ_OFFSET_H
193
+ dt_local = dt.astimezone(timezone(timedelta(hours=offset)))
194
+ t_min = dt_local.hour * 60 + dt_local.minute
195
+ t_str = dt_local.strftime("%H:%M CDT")
196
+ return t_min, t_str
197
+
198
+
199
+ def aviationstack_to_arrivals(raw: list[dict], start_h: int, end_h: int) -> pd.DataFrame:
200
+ """Parse AviationStack arrivals → standard DataFrame. Times converted UTC→DFW local."""
201
+ from datetime import datetime, timezone as _tz
202
+ rows = []
203
+ for f in raw:
204
+ arr = f.get("arrival") or {}
205
+ dep = f.get("departure") or {}
206
+ origin = dep.get("iata")
207
+ if not origin or origin == "DFW":
208
+ continue
209
+ # Prefer actual > estimated > scheduled
210
+ sched = arr.get("actual") or arr.get("estimated") or arr.get("scheduled")
211
+ if not sched:
212
+ continue
213
+ try:
214
+ dt = datetime.fromisoformat(sched.replace("Z", "+00:00"))
215
+ t_min, t_str = _to_dfw_local(dt)
216
+ except Exception:
217
+ continue
218
+ if not (start_h * 60 <= t_min < end_h * 60):
219
+ continue
220
+ flt = f.get("flight") or {}
221
+ airline = (f.get("airline") or {}).get("iata", "")
222
+ rows.append({
223
+ "airport": origin,
224
+ "time_min": t_min,
225
+ "time_str": t_str,
226
+ "flight": flt.get("iata", "AA?"),
227
+ "Tail_Number": (f.get("aircraft") or {}).get("registration", ""),
228
+ "carrier": airline,
229
+ })
230
+ return pd.DataFrame(rows) if rows else pd.DataFrame()
231
+
232
+
233
+ def aviationstack_to_departures(raw: list[dict], start_h: int, end_h: int) -> pd.DataFrame:
234
+ """Parse AviationStack departures → standard DataFrame. Times converted UTC→DFW local."""
235
+ from datetime import datetime
236
+ rows = []
237
+ for f in raw:
238
+ dep_info = f.get("departure") or {}
239
+ arr_info = f.get("arrival") or {}
240
+ dest = arr_info.get("iata")
241
+ if not dest or dest == "DFW":
242
+ continue
243
+ sched = dep_info.get("actual") or dep_info.get("estimated") or dep_info.get("scheduled")
244
+ if not sched:
245
+ continue
246
+ try:
247
+ dt = datetime.fromisoformat(sched.replace("Z", "+00:00"))
248
+ t_min, t_str = _to_dfw_local(dt)
249
+ except Exception:
250
+ continue
251
+ if not (start_h * 60 <= t_min < end_h * 60):
252
+ continue
253
+ flt = f.get("flight", {})
254
+ airline = (f.get("airline") or {}).get("iata", "")
255
+ rows.append({
256
+ "airport": dest,
257
+ "time_min": t_min,
258
+ "time_str": t_str,
259
+ "flight": flt.get("iata", "AA?"),
260
+ "Tail_Number": (f.get("aircraft") or {}).get("registration", ""),
261
+ "carrier": airline,
262
+ })
263
+ return pd.DataFrame(rows) if rows else pd.DataFrame()