ALTO

Methods

Why aviation

I'm a systems person. I like it when there's a real process behind something, and I like being the one who builds it. Something like a hundred thousand flights take off every day around the world, and almost everyone on one experiences the same quiet trick: you show up, hand over a bag, sit down, and a few hours later you're somewhere else. You never think about what had to go right. A gate had to be open. A crew had to still be legal to fly. A bag had to make a connection with nine minutes to spare. Weather three states away had to already be worked around before it became your problem. None of that is visible from seat 14C. Invisible means it worked.

That's the part I want to be part of. A huge, complicated system runs underneath that seat, kept running by thousands of people whose names never make the announcement. I picked gates and turns to prove myself on here because it's a problem I could actually get my hands on with public data, not because the pull stops at gates. The same instinct applies anywhere an airline has to find the real best answer instead of the answer it has always used, whether that's pricing a route, staffing a hub, or deciding which market to grow next. Ops is where I'd start, since it's closest to what I can already see and measure. I don't want to limit myself to only that. ALTO is me proving I can think this way for real, with real data, wherever an airline needs it.

A note on how this was built

I'm an IEOR major. I don't write Python day to day, and Claude wrote every line of code on this site. What's mine is the model itself: the constraints, the objectives, figuring out where it was wrong and how to fix it. I decided on sequential pairing over a join. I decided on a capacity-driven tow rule over a duration cutoff. I decided what a disruption should charge for and what it shouldn't.

The writing on this page is mine too, and everything that fed into it: an actual account of my thought process, the roadblocks, the mistakes, and everything else. Claude wrote the code — the interactive website, the API, and the backend pieces that got complicated enough that doing them alone would have taken a lot longer.

Claude took what I built and turned it into something better than what I'd made alone: an interactive site that's more dynamic, and accessible to more than just me. It also runs faster and more efficiently than my own version did.


ALTO assigns Alaska's daily aircraft turns at Seattle‑Tacoma to gates, prices what a disruption costs, and measures how much of that re-planning gets back. This page covers where the data came from, how the model is put together, the mistakes I made building it, and what it cannot tell you.

1 · The data 2 · Building turns 3 · Where aircraft park 4 · The model 5 · Validation 6 · The cost model 7 · What a disruption costs 8 · What this cannot tell you

1 · The data

The source of all flights is U.S. Bureau of Transportation Statistics Airline On-Time Performance dataset for 2023 with filter set for carrier code AS with either origination or destination airport SEA. That leaves me with 139,000 flight records and 155,246 leg-records after excluding canceled or diverted flights.

I picked BTS as my source because it has data for wheels down and wheels up moments. Gate occupancy relies on exact arrival times.

Clock times are converted to absolute time values of minutes since 1 January 00:00. As clock times in BTS are recorded in HHMM form (integers), comparisons like 1438 from 15th against 0410 from 16th will produce wrong order of time values and each overnight turn will be treated as exceptional situation. One integer per time value solves the problem.

Overnight arrivals must be corrected. Departure time belongs to the date of departure, but an overnight arrival is stamped with a departure date, so a 04:10 red-eye arrival goes to the previous date. I detect such situations by checking whether arrival clock time precedes departure clock time.

Does this look like Seattle?

I first verified whether the shape of the data was consistent with what the airport reports before accepting anything from the model. The Port of Seattle reported August as the busiest travel month while TSA expects that the busiest checkpoint days would be Sundays, Mondays, Thursdays, and Fridays. They are both external verifications. None of these went into building anything.

DayMean aircraft visitsNamed busy by the Port and TSA
Friday227.3yes
Monday227.2yes
Thursday227.2yes
Sunday224.0yes
Wednesday214.9no
Tuesday212.5no
Saturday199.1no

In this airport, there are the four busiest days that it names and the three quietest days that it does not name. The month rankings mirror the above pattern, where August is the most crowded at 254.9 per day and February is the least crowded at 187.1 visits.

The TSA's busiest day of the year 2023 is the Sunday following Thanksgiving, where 2.9 million passengers pass through airports. However, in this dataset, Thanksgiving Day is the least crowded day of the year. It is possible for both to happen. The former indicates that there are crowded aeroplanes while the latter that there are many aeroplanes in general; a gate is used by an aeroplane.

Here, the busiest day is 28th July with 267 visits. The lead here is not significant since the top ten busiest days range from 267 down to 263. They occur either in July or August. This explains the reason why the position of the busiest date changes whenever I include arrival-only and departure-only visit counts. When ranking gates blocks-wise, 7th August emerges as the busiest day with 382. The same is the case when ranking by fees; 7th August comes out tops again. When ranking stands required, it is 27th July with 36 stands.

2 · Building turns

A turn is an aircraft's time on the ground. It arrives, stays at the gate, and leaves. The first attempt to create turns used a pandas merge based on tail number and date, which is a cartesian join.

One aircraft, one day

Tail N434AS came through Seattle twice on 15 July 2023:

TimeEvent
14:38arrives from JFK
15:51departs for SFO
21:47arrives from SFO
23:28departs for JFK

Two visits. The merge produced four pairings, and my follow-up filter caught only one of the bad ones.

PairingGround timeVerdict
14:38 → 15:5173 minreal
14:38 → 23:28530 mininvented, and kept
21:47 → 15:51—removed by the time filter
21:47 → 23:28101 minreal

So this way, N434AS had to guard a gate between mid-afternoon and close to midnight for the plane to be in San Francisco. During the year 2023, that increased 75,347 turns to 79,052 turns, raised the 75th percentile time from 106 minutes to 305, and peaked on 15 July at 76 from 29 before. There were only 57 gates at Alaska, and thus, there was no way to solve the problem of assignments, and it was impossible to obtain an answer.

The workaround goes through the schedule of each plane and assigns each arrival to the next unused departure for the same plane, using each departure just once. Phantom turns do not even get created.

It is because I checked the distribution of turn times with the dwell time of a 737 on the ground. A 75th percentile of five hours is not a turnaround.

What gets discarded

RuleReasonCount
Ground time under 20 minutesPhysically impossible for a 737, so a bad time in the source5
Gap over 36 hoursToo long for one visit; the aircraft left on a flight outside this data2,071
No matching departureKept now, as an arrival-only visit—

The tariff supports the 36-hour cutoff. The Port of Seattle charges $200 per 12-hour period for overnight parking and $5,000 per 12-hour period beyond 24 hours. Aircraft do not sit.

Aircraft that only arrive, or only depart

Defining a turn as an arrival paired with a departure dropped a whole category of gate use. An aircraft that ends its day in Seattle has no departure to pair with, and one that starts its day here has no arrival. Both take a gate.

Counted against the raw filesRows
Arrivals into SEA77,526
Departures from SEA77,720
Paired turnarounds75,347
Arrivals with no departure2,179
Departures with no arrival2,373

This is around 12.5 times the number of gate usage per day that the model could not identify, hence the reason the flight picker would offer the aircraft flying through Seattle alone.

The arrivals and departures are designed as either arrival-only visits or departure-only visits. The length is defined by the tariff, which has different maximum time for each of them, that is 60 minutes for arrival-only visits and 75 minutes for departure-only visits, 100 to 199 seats.

The number of visits after the data restructuring is 79,899 visits and 108,050 occupancy blocks. Peak stand demand range between 23 and 39 stands per day, median being 31, with no day exceeding the 73-stands list.

3 · Where aircraft park

Seattle-Tacoma publishes a maximum gate occupancy. The numbers in this section come from that document.

AircraftTurnaroundArrival onlyDeparture only
400+ seats180 min75120
200–399 seats150 min6090
100–199 seats, Alaska's 737-800 and -900120 min6075
Under 100 seats90 min4545

Beyond this limit, the airport levies a gate delay charge in $250 increments for every 15 minutes: $250 for the first hour and a half, $500 for the next two-and-a-half hours, and $1,000 thereafter. An aircraft left overnight at the gate would pay a hefty price tag of tens of thousands of dollars.

This concept can be stated more simply according to industry practice. An aircraft being put out of service for the night gets parked in a remote area of the airport, or at least moved away from the departure gate. The process is known as Remain Overnight.

Thus, any turn exceeding the maximum published limit is a pair of gate blocks. The arrival, at the arrival limit of 60 minutes only. The aircraft moves away. The departure, at the departure limit of 75 minutes only.

What parking away costs

The tariff charges for it in steps.

up to 4 hours           $100   remote hardstand fee
each of the next two    $200   per 12-hour period
every period after that $5,000   per 12-hour period

That last step is punishing enough to stop carriers using Sea-Tac as a car park, and it means two days of sitting costs vastly more than six hours. My first version charged a flat $100 and lost all of it.

A rule I got wrong

That model for about a day operated only in cases when another aircraft required the gate, based on the idea that no one wants it at three in the morning. I have never double-checked that reasoning from any source, but the logic was wrong.

Limits on occupation and "Remain Overnight" restrictions for the tariff are both designed to prevent the aircraft from parking at a gate.

Evidence which looked convincing back then was empty gates during the night and full remote stands. This is exactly how hub looks like at five in the morning.

Each parameter now has a recorded source, and is either verified, derived or assumed. Two parameters are partially verified and both note what is left unclear.

4 · The model

Two solvers run the same problem. One is fast enough to sit behind a slider. The other is the literal statement of the problem, and I use it to check the first.

The integer program

Variables
  x[b,g] = 1 if block b is parked at gate g, else 0
  y[g]   = 1 if gate g is used at all that day, else 0

Minimize   F · Σb,g∈S x[b,g] + M · Σg y[g] + Σb,g walk[g] · x[b,g]

Subject to
  Σg x[b,g] = 1             ∀ b    every block gets exactly one gate
  Σb∈Q x[b,g] ≤ y[g]    ∀ Q, g  overlapping blocks cannot share a gate
  y[g] ≥ y[g+1]           ∀ g    symmetry breaking

The three terms are ordered rather than combined. S is the set of common-use stands, priced by turn, so F takes precedence over M. M outranks the highest possible walking distance in a day; so no matter how far you walk, a further gate will not be justified. Walking defines what remains.

There are two reasons why it can be solved. The first reason is that Q iterates over maximal sets of mutually overlapping blocks rather than pairs of blocks because the occupation pattern of gates forms an interval graph and any set of mutually overlapping blocks is a subset of one of them. On 15 July, that makes 125 constraints for the groups compared to 4,398 pairwise constraints, and each constraint is stricter. The symmetry breaking then prevents the solver from concluding that thousands of relabelings of gates are equivalent solutions.

Network Flow Solution

Forget about gates and focus on chains. Each gate serves one aircraft, then another, then another. So the optimal number of gates is equivalent to the optimal number of chains covering all the blocks, which is the problem of minimum-cost path cover, which is a network flow.

each block becomes two nodes, a front door and a back door

SOURCE → in[i]    open a gate for block i    cost: high
out[i] → in[j]    j follows i on the same gate cost: idle minutes
out[i] → SINK     i is last on its gate       cost: 0
SINK → SOURCE     the return pipe, whose flow is the gate count

It receives one unit through its front door and sends another unit through its rear door, thus making sure that the output forms clean chains without overlap. The Hungarian method matches these chains to specific gates in accordance with criteria such as common use fee, number of airplanes to be re-routed, and finally the walk.

Actually solving it

The integer program is built with PuLP and solved with the bundled CBC solver. Both goals, fewest gates then least walking, are folded into one objective by pricing a gate above the total walking cost the whole day could possibly run up, so no amount of walking ever buys an extra gate. A common-use turn is priced above that again, at the gate price times the block count, because the first version priced it too low and the solver spent a real $552.89 fee to save a gate that costs nothing at all, six extra common-use turns to save one gate, and reported the result as a $21,010 improvement.

From solver_ilp.py:

worst_possible_walk = len(blocks) * max(walk_cost.values())
gate_price = worst_possible_walk + 1

gates_term = pulp.lpSum(y[g] for g in gate_ids) * gate_price
walk_term = pulp.lpSum(
    x[b][g] * walk_cost[g] for b in block_positions for g in gate_ids
)

common_use_price = gate_price * len(blocks)
common_use_term = pulp.lpSum(
    x[b][g] for b in block_positions for g in gate_ids if str(g).startswith("S")
) * common_use_price

problem += common_use_term + gates_term + walk_term

Rule 2, blocks on the ground together cannot share a gate, is written as one constraint per group of mutually overlapping blocks rather than one per pair: 125 groups instead of 4,398 pairs on 15 July. Symmetry breaking, forcing gate 1 to open before gate 2 and so on, is the single biggest speed-up in the file. Without it every gate looks the same to the solver, so a plan using gates 1 through 29 and a plan using gates 2 through 30 are identical in value, and it burns enormous effort proving thousands of such relabellings are all equally good. CBC gets 150 seconds, not 300, because nginx times out a request at 180 and a solver allowed to run longer than the proxy waits just hands the slowest days back to a visitor as an error page.

From solver_ilp.py:

groups = schedule.overlap_groups(blocks)
for group in groups:
    for g in gate_ids:
        problem += pulp.lpSum(x[b][g] for b in group) <= y[g]

for position in range(len(gate_ids) - 1):
    problem += y[gate_ids[position]] >= y[gate_ids[position + 1]]

The flow network and the Hungarian algorithm

The fast solver thinks in chains, not gates. NetworkX builds a graph with two nodes per block, a front door and a back door: SOURCE feeds the front door of any block starting a new chain, and the back door either feeds SINK, ending the chain, or the front door of whatever block follows it on the same gate. A return pipe from SINK back to SOURCE is what lets the solver decide how many chains to open at all. Wiring the source to instead send one unit per block forces every block into its own chain, and once reported 289 gates needed for a 29-gate day. Idle minutes between two linked blocks become the cost of that edge, scaled by ten so a half-minute tie-break survives networkx's whole-number weights, and a link that matches the previous plan gets a tenth of a minute knocked off it, enough to make the solver prefer it without changing which plan is optimal.

From solver_mcnf.py:

network.add_edge(SINK, SOURCE, capacity=block_count, weight=0)

for position in range(block_count):
    network.add_node(("in", position), demand=1)
    network.add_node(("out", position), demand=-1)

    network.add_edge(
        SOURCE, ("in", position),
        capacity=1,
        weight=config.GATE_OPENING_PRIORITY_COST * WEIGHT_SCALE,
    )
    network.add_edge(("out", position), SINK, capacity=1, weight=0)

for earlier in range(block_count):
    for later in range(block_count):
        if earlier == later:
            continue
        if not schedule.can_follow(ends[earlier], starts[later]):
            continue

        idle_minutes = starts[later] - ends[earlier] - config.MIN_GATE_BUFFER_MINUTES
        weight = int(idle_minutes) * WEIGHT_SCALE
        if (earlier, later) in previous_links:
            weight = weight - STICKINESS_BONUS

        network.add_edge(
            ("out", earlier), ("in", later),
            capacity=1,
            weight=weight,
        )

Flow only produces chains, not gate names, since it treats every gate as interchangeable. Naming them is a separate problem, solved exactly by the Hungarian algorithm, scipy's linear_sum_assignment, on a cost matrix of walking distance plus common-use fee plus a penalty for moving an aircraft off the gate it already had. That penalty used to be 1000, set back when walking was the only other term in the matrix. Once the common-use fee was added it kept outranking the real $552.89 fee, so the solver would rather pay an actual invoice than shuffle a plane between two gates, and the re-plan came out worse than doing nothing on six of ten days sampled. It is 50 now, above the worst walking cost per block of 7.6 and below what a common-use turn actually costs.

From solver_mcnf.py:

MOVE_PENALTY = 50.0

cost_matrix = np.zeros((len(chains), len(gate_ids)))

for chain_index, chain in enumerate(chains):
    for gate_index, gate_id in enumerate(gate_ids):
        walking = len(chain) * walk_costs[gate_index]
        fees = len(chain) * common_use_fee[gate_index]

        if previous_assignment is None:
            cost_matrix[chain_index][gate_index] = walking + fees
            continue

        moves = 0
        for position in chain:
            was_at = previous_assignment.get(position)
            if was_at is not None and was_at != gate_id:
                moves = moves + 1

        cost_matrix[chain_index][gate_index] = fees + moves * MOVE_PENALTY + walking

chain_rows, gate_columns = linear_sum_assignment(cost_matrix)

5 · Validation

An answer in a millisecond is only useful if it is also the correct answer. The following three independent techniques are performed each day:

The 16 days all give the same number of gates. They range from the quietest February weekdays to July 4th, Thanksgiving, Christmas Eve and the busiest day in the data set. Network flow takes an average of 0.3 seconds compared to 12 for the integer program, so on average it is 24× to 60× faster.

Where the fast solver is worse

The number of gates is optimal in both solvers. Walking cost is 9.3% higher in the network flow on all 16 days.

The problem lies in the structure of the solution. Since flow regards all gates as identical, it builds chains of gates first, then assigns names later, whereas the integer program considers chains and gate names simultaneously and arrives at solutions flow cannot achieve. The optimal allocation of chains to gates is proved optimal regardless of chain choice, so the difference must be in the chain structure itself and not in per-gate cost.

The same structure results in higher cost than walking when gates are few. No chain can be broken, so closing 39 gates leaves 18 leased stands but 31 chains, of which thirteen are allocated common use stands carrying every turn in those chains along with them. On 15 July that resulted in 83 turns at common use stands compared with 50 when placed in single block mode. The integer program has no chain restriction and managed 48 on the same day. On the day measured, the flow solver's re-plan was $12,439 more expensive than the improvised one, and the integer program's was $7,306 cheaper.

Thus the model page provides a Solve exactly checkbox, and when the fast solver cannot beat the improvised plan it keeps the latter.

6 · The cost model

ALTO prices three things: delay, aircraft parking and towing, and common-use gate charges. Those are the costs a gate plan can change.

It leaves out fuel, crew, maintenance, landing fees, ground handling and the rent on Alaska's own gates. Those are real money and much larger, and they are also the same whichever plan you pick, so adding them would put a constant on every scenario and say nothing about which plan is better. Every figure on this site is a difference between plans, and every component is something an airline gets invoiced for.

ParameterValueSource
Delay cost$98.41 / minA4A 2025 direct aircraft operating cost per block minute
Common-use gate, narrowbody$552.89 / turnPort of Seattle tariff, 1 Jan 2025, signatory rate
Remote hardstand$100 / usePort of Seattle tariff
Gate idle time$6.14 / minRetired, see below
Delay propagation0.75An assumption. No published figure exists

$98.41 consists of $37.01 for crew, $29.34 for fuel, $18.35 for maintenance, $9.76 for aircraft ownership and $3.95 for other costs. This is the cost of the airline itself. A4A estimates the value of passenger time at approximately $47 per hour, while ALTO does not take it into account, so every disruption cost presented here is underestimated.

There is no source for the propagation factor, which is the reason why it is a slider.

Why idle gate time is no longer priced

It was priced at a calculated $6.14 per minute, the only estimate of the cost model which I could not get from the tariff. It produces a result which is simply impossible. Closing gates N1, N11 and N20 on 17 June generated a disruption cost of minus $682, thus closing gates seemed to make a profit.

Idle was calculated as the gap between consecutive aircraft at the occupied gates, so closing some gates would reduce the amount of idle times. Neither solver minimises idle time, thus it was never optimised but only observed, and an improvised solution following the closure would perform better than the optimal one in terms of idle. Idle was the biggest component of the $105k disruption cost against $146k total.

Underneath that, the idle time is not an expense. The rent on the preferential gate is established by the lease agreement and does not change according to the plan, so adding it to a comparison of plans introduces a big number which has no informative value for the decision. Everything else is an invoice. Gate occupancy is measured by a utilisation rate.

7 · What a disruption costs

Multiplication of the delays caused by injected delays with the costs per minute is incorrect since there are more impacts associated with such delays: the arrival is late, uses the gate that was assigned to another plane, which, in turn, is delayed, flies late and arrives late at its destination airport and comes back late to Seattle in the evening. Each arrival delays six departures.

Therefore, the damage number will be generated via the simulation run in the direction of the day, while keeping the gate assignment plan fixed under two influences that feed each other:

Since both make each other worse, this process iterates rather than repeats once, each time taking the delays one leg further ahead, but limited to four legs maximum since after that the influence is less than the estimation error.

Doing nothing, in case of gate closure

In my first solution, the plane that lost its gate was dropped from the schedule and dropping the plane has zero cost, so I had to close six gates in order to save $26,207.

What can be considered as the correct comparison is optimizing the system with respect to actions of the gate controller under pressure. In this case, the displaced planes should be scheduled in the cheapest available stands starting with leased ones. Any aircraft that doesn't fit anywhere is parked at remote hardstand and the fee for using it should be paid.

When I tried sending all planes directly to hardstands that cost $100, compared to $552.89 for a common-use turn, the doing nothing turned out to be more profitable than re-scheduling. Hardstands are not substitutes for gates, since the plane on it cannot be boarded; therefore, the hardstand is used as the place where the plane ends up being when there are no more stands.

One final rule ties the comparison together. Closing a gate cannot force an aircraft to depart early. Absent this, removing aircraft from gates eliminated the aircraft from the gate fight, the simulated delay was lower, and closing more gates cost less than closing fewer gates. The direction is definite while the magnitude is not, so the damage done runs on whatever departure time is later, its own or the other one from that block with no closures.

Minimum-disruption recovery

A recovery strategy that conserves a fraction more of margin but disrupts two hundred flights is not something that operations can implement. Two measures ensure that the result remains feasible. The chain-to-gate mapping tracks the number of flights departing their home base gates, and links that appear in the current recovery plan receive a small discount in the flow itself, such that the best plans that are close are favored.

In a scenario where a three-flight delay brought the number of disrupted aircraft down from 265 to 56, at a cost of 2.3 percentage points of recovered share. I made this trade deliberately.

8 · What this cannot tell you

Regional flying is missing

Neither Horizon nor SkyWest fly E175s in Alaska colors and use Alaska gates. Horizon does not report BTS On-Time Performance figures; the 2023 files include fifteen carriers - 9E, AA, AS, B6, DL, F9, G4, HA, MQ, NK, OH, OO, UA, WN and YX. No Horizon, so there are no planes. SkyWest is reported but cannot be attributed. In January 2023 SkyWest flew 3,170 flights in SEA compared to 11,605 by Alaska mainline, and flies for Alaska, Delta, United and American. I tried attributing them three ways but failed. There is no marketing-carrier and codeshare fields, flight numbers do not distinguish partners because both 3000- and 4000-series operate on Pacific Northwest routes from SEA, neither do the aircraft themselves, as 103 out of 104 SkyWest aircraft that flew in SEA in January also touched other mainline's hub.

Using route heuristic would have produced a plausible number, but with no data behind it, so I chose to leave the hole in place and note it. All utilisation figures here are minimum estimates for Alaska mainline but not estimates of the Alaska brand total gate demand. Proper attribution of these aircraft means BTS's separate Marketing Carrier table, which includes both operating and marketing carriers, and would still exclude Horizon.

The rest

There is no publicly available set of gates assignments. There is no way to know the actual gate assignment from public data sources, hence the ALTO algorithm generates an optimal gate assignment and not an accurate reconstruction of the actual gate assignment, without the ground truth available. Solver accuracy is compared to each other and to theoretical results in section 5.

The list of gates is a modeling assumption. The airport has 67 preferential use gates according to its current airline agreement and Concourse C and the North Satellite belong to Alaska Airlines. The list of gates and the walking cost coefficients are derived from the publicly available terminal map.

The 2023 flights are priced using the 2025 rates. The rates and the agreement are up to date while the flights are two years old, however that is fine since they are used only for relative comparison.

Disruption costs do not include passenger time value, which is about $47 per hour according to A4A. It will definitely increase disruption costs if included.

Sources