Airline operations¶
simulsi.aviation turns a flight schedule into a stochastic model of the
operation. You can use it to:
- forecast tomorrow's flights the day before;
- re-forecast during the day from the live state (a simple digital twin);
- test controller actions and search for good ones;
- size reserves, schedule buffers and gates;
- calibrate on history, and check that the forecasts mean what they say.
Everything below works from Python, from the command line (simulsi
aviation ...) and, for forecasts, in the dashboard ("Airline ops twin"
page). All examples use a synthetic hub-and-spoke network.
from simulsi.aviation import Schedule, OpsConfig, forecast, thunderstorm
schedule = Schedule.synthetic() # 12 aircraft, 72 flights, 9 airports
rules = OpsConfig(runway_rate={"HUB": 30}, gates={"HUB": 10}, curfew={"HUB": 1410})
fc = forecast(schedule, rules, replications=100,
weather=[thunderstorm("HUB", "15:00", hours=3, probability=0.5)])
print(fc.format(top=5))
How the day is simulated¶
Each aircraft (tail) flies its rotation as a simulsi process. A departure waits for three things:
- the aircraft: the inbound arrival plus the minimum turn time;
- the crew: its previous flight plus the crew connection time, which may be on another aircraft;
- any known estimate, such as an ETD from the live status.
On top of that, each departure gets a random primary delay (technical, ATC, late passengers). At airports with a runway rate or weather, it then queues for a departure slot. Arrivals at those airports also queue for a landing slot and wait out closures. At airports with a gate limit they may wait for a gate.
About an hour before each departure, operations control looks at the projected delay and may act:
| Situation | Action |
|---|---|
Aircraft projected more than swap_threshold late, spare at the airport |
Spare takes over the rotation; the late aircraft becomes the spare |
Inbound crew late, or crew would break duty_limit |
Call a standby crew, else cancel |
Projected delay above cancel_threshold |
Cancel the round trip (the aircraft stays put) |
Departure or arrival after the airport's curfew |
Cancel the rest of the rotation |
After the day, a passenger connection counts as missed when either flight
is cancelled, or when the outbound leaves less than mct minutes after the
inbound arrives. The cost of the day is:
- delay minutes x
delay_cost_per_minute; - plus cancellations x
cancel_cost; - plus misconnected passengers x
misconnect_cost_per_pax; - plus, with
eu261=True, EU261 compensation. Weather exemptions are not modelled, so this compensation figure is an upper bound.
Every flight draws its random delays from a fixed position. As a result, two plans that differ only by an action see the same disturbances (common random numbers). This makes comparisons between options sharp.
Schedules¶
A schedule is a CSV with one row per flight:
flight,tail,origin,dest,std,sta,pax,crew
F100,T01,HUB,AAA,06:00,06:50,170,C01a
F101,T01,AAA,HUB,07:35,08:25,152,C01a
How the columns are read:
- Times may be
HH:MM, with hours past 23 for the next day, minutes, or ISO date-times. - Arrivals before departure are taken as next-day arrivals.
paxandcreware optional. Without crews, crew connections are not modelled.- Column names are flexible:
flight_no,registration,from/to,departure/arrivaland similar also work.
Passenger connections go in a second CSV with the columns
inbound,outbound,pax.
Schedule.validate() lists problems such as:
- an aircraft that "teleports" between airports;
- overlapping legs;
- connections that leave before the inbound arrives.
from simulsi.aviation import Schedule
s = Schedule.synthetic(tails=6)
print(s, s.validate())
print(s.to_csv().splitlines()[:3])
Operating rules¶
OpsConfig holds the rules of the day. Per-airport settings are
dictionaries; an airport that is not listed is unconstrained. In YAML
(simulsi aviation example writes one):
on_time: 15
min_turn: 35
runway_rate: {HUB: 30} # departures (and arrivals) per hour
gates: {HUB: 10}
curfew: {HUB: "23:30"}
duty_limit: 780 # minutes
spares: {HUB: 1}
standby_crews: {HUB: 2}
swap_threshold: 90
cancel_threshold: 240
mct: 35
eu261: true
delays: {prob: 0.25, mean: 35, block_cv: 0.06}
weather:
- "HUB 15:00-18:00 0.4 p=0.5"
delays is the DelayModel. It sets:
- the chance and mean size of a primary delay, overridable per airport
(
"LHR") or per airport and hour ("LHR@07"); - block-time variability, with an optional per-route bias;
- the empirical shape of delays and a day effect: a whole day is
better or worse than average (
day_sigma), and each airport also has good and bad days (airport_days, quantiles of a daily factor); - cancellations not caused by delay (
cancel_rate, orcancel_days: quantiles of the daily cancelled share per airport). An airport's bad delay day is also its bad cancellation day; both use one draw.
Calibration fits all of these from history (see below).
Weather events cut an airport's departure and arrival rate to capacity
times normal during a window, with a probability. Each simulated day
draws whether the event happens. An event can also cancel flights
outright: "JFK 06:00-20:00 0.3 p=1 c=0.4" cancels each departure in the
window with probability 0.4, as airlines do ahead of a forecast storm.
There are presets: thunderstorm, morning_fog, snow and closure.
Other settings:
| Setting | Meaning |
|---|---|
spare_ferry_minutes |
when set, a spare at another airport can be ferried in, ready after this many minutes; otherwise spares only cover their own airport |
load_factor |
used to estimate seats when the schedule has none, for rebooking |
overnight_delay |
delay charged to a passenger who cannot be rebooked the same day |
late_turn_compression |
how much of the minimum turn a late aircraft can save (1 = none); fitted by fit_late_turns |
otp_recalibration |
(a, b) Platt correction of the reported on-time chances, fitted by fit_recalibration |
The day before: forecasts¶
forecast simulates the day many times and summarises it:
- per flight: the chance of an on-time arrival (A15), the chance of cancellation, departure-delay quantiles (p50/p80/p95), the main cause of delay, crew-duty risk and expected misconnecting passengers;
- per rotation:
fc.rotations()ranks aircraft by expected delay and cancellation risk, so you can see which rotations need more buffer; - per connection: the chance each connection is missed
(
fc.connection_risk); - network: OTP, cancellations, misconnections and cost, each with an 80% range;
- alerts: flights and connections that cross risk thresholds.
from simulsi.aviation import Schedule, OpsConfig, forecast, closure
fc = forecast(Schedule.synthetic(), OpsConfig(runway_rate={"HUB": 30}),
replications=60, weather=[closure("HUB", "09:00", 120)])
for alert in fc.alerts()[:3]:
print(alert)
print(fc.rotations()[0])
fc.to_csv(), fc.to_json() and fc.gantt() (rows for a tail-by-time
chart) export the results.
During the day: the live twin¶
OpsState holds what has happened so far: actual departure and arrival
times, known ETDs, cancellations and aircraft out of service. A status CSV
has the columns flight,atd,ata,etd,status; rows with status=aog and a
tail take an aircraft out of service until its etd. Pass the state to
forecast to simulate only the rest of the day. Flights that already flew
keep their real times, and everything else is projected from them.
Re-running this every 15 minutes gives a rolling forecast.
rolling_forecast lets you test the idea on a known "truth" day. It
re-forecasts at several times and scores each forecast against what
happened. The uncertainty narrows as the day unfolds:
from simulsi.aviation import Schedule, OpsConfig, simulate_day, rolling_forecast
s, rules = Schedule.synthetic(), OpsConfig(runway_rate={"HUB": 30})
truth = simulate_day(s, rules, seed=123)
for row in rolling_forecast(s, rules, truth=truth, times=[300, 720, 1080], replications=40):
print(row)
OpsState.load(source) reads the state from a CSV or JSON file or an
http(s) URL (for example an internal feed that exports the status
columns). JSON can be a list of rows or {"now": "12:00", "flights":
[...]}. simulsi aviation watch --feed URL --every 300 re-reads the feed
and re-forecasts every five minutes, printing the alerts and optionally
writing the latest forecast JSON for another system to pick up.
The dashboard's Airline ops twin page runs the same forecasts, and its
Decisions panel runs recover and plan_reserves on the same schedule,
rules, weather and live state as background jobs.
Actions change the plan:
Cancel("F1", "F2")cancels flights;Retime("F1", 20)holds a departure;Swap("T01", "T02", after=720)swaps two aircraft's remaining rotations at the same airport.
compare_plans simulates options on the same disturbances and reports the
paired cost difference with a 95% interval. recover searches greedily.
It starts from candidate actions for the riskiest flights: round-trip
cancellations, aircraft swaps, and holds for big connecting groups. It
keeps an action only if its saving is clearly larger than the simulation
noise.
from simulsi.aviation import Schedule, OpsConfig, Cancel, compare_plans, recover, closure
s, rules = Schedule.synthetic(), OpsConfig(runway_rate={"HUB": 30}, curfew={"HUB": 1410})
storm = [closure("HUB", "09:00", 150)]
print(compare_plans(s, {"cancel F100/F101": [Cancel("F100", "F101")]}, rules,
replications=30, weather=storm).format())
print(recover(s, rules, weather=storm, replications=20, max_actions=2).format())
Planning¶
| Function | Question |
|---|---|
plan_reserves |
How many spare aircraft and standby crews to hold at the hub: reserve cost against expected disruption cost, with the Pareto front |
optimize_buffers |
Where to add a budget of extra ground time. It goes to the turns where knock-on delay is largest, weighted by how many later legs each turn protects |
schedule_impact |
What a schedule change does (new rotation, retimed bank, banked vs rolling hub), simulated on matched disturbances |
network_model |
The day as a regular simulsi Model with parameters (spares, standby_crews, thresholds, min_turn, delay_scale, weather), for experiments, optimize and pareto_search |
from simulsi.aviation import Schedule, OpsConfig, DelayModel, plan_reserves, schedule_impact
rules = OpsConfig(runway_rate={"HUB": 30}, delays=DelayModel(prob=0.25, mean=60))
print(plan_reserves(Schedule.synthetic(), rules, spares=(0, 1), standby_crews=(0, 2),
replications=20).format())
print(schedule_impact(Schedule.synthetic(), Schedule.synthetic(banks=False), rules,
names=("banked", "rolling"), replications=20).format())
Passengers and ground¶
checkin_staffingbuilds a counter plan per 15 minutes. It starts from Erlang C per slot, then a simulation checks the time-varying plan and adds counters where queues carry over from busy slots.overbookingfinds the booking limit that maximises expected revenue minus denied-boarding cost.eu261_compensationgives the compensation per flight.turnaroundsimulates ground tasks (deboarding, cleaning, catering, fuelling, bags, boarding) as processes that wait for their predecessors. It reports the turn-time distribution and each task's criticality: how often it lies on the critical path.min_turn(0.95)is the scheduled turn that is long enough on 95% of days. Shared teams (crews={"clean": 1}) show bank congestion.recommend_mctgives the minimum connection time for a reliability target, from inbound and outbound delays and walking time.
Every simulated day also re-accommodates disrupted passengers: those on a
cancelled flight or who miss a connection are put on the next direct
flight to their destination that still has seats. The day's metrics
include rebooked_pax, stranded_pax (no seat until tomorrow) and
disrupted_pax_delay_hours, the total arrival delay of those passengers.
from simulsi.aviation import turnaround, overbooking, recommend_mct
r = turnaround(replications=300)
print(r.format())
print("booking limit for 180 seats:", overbooking(180, 0.92).best_limit)
print("MCT for 95%:", recommend_mct(samples=5000).mct, "min")
Calibration and backtesting¶
Calibration starts from History, which reads a CSV of actual flights.
It accepts the generic columns date, flight, tail, origin, dest, std,
sta, atd, ata, cancelled. It also reads the US BTS on-time files, in both
the download format (FL_DATE, TAIL_NUM, CRS_DEP_TIME, DEP_DELAY, ...)
and the monthly ZIP format (FlightDate, Tail_Number, CRSDepTime, ...):
carrier="AS"keeps one airline.- BTS times are local. When scheduled block times are present, each
airport's offset is inferred from the data and all times are put on one
clock (
history.clock,history.tz_offsets). history.schedule(date)splits rotations broken by diversions or missing legs, so every day validates.history.state(date, now)rebuilds what was known at a given time.
There are two ways to fit the delay model:
fit_delay_modelsubtracts each departure's knock-on delay from a late inbound aircraft. The rest counts as primary delay, and the function estimates its chance and size per airport, the empirical shape of delays (heavier-tailed than an exponential), and actual against scheduled block time.calibraterefines that estimate by simulation. The direct estimate also counts runway queues and crew waits, which the simulation adds by itself.calibratere-simulates historical days and adjusts each airport's parameters until the simulated share of late departures and the mean delay match history. It also setsday_sigmaso that whole days vary together as much as real ones do.
fit_turn_times estimates each airport's minimum turn from the fastest
turns actually flown. fit_late_turns checks whether late aircraft turn
faster than on-time ones.
BTS files also carry delay causes (carrier, weather, NAS, security, late
aircraft) and cancellation codes. history.causes() summarises them,
which shows how much of the delay the model must produce by itself
(late aircraft) and how much is primary. History.concat joins months.
fit_recalibration fits a Platt correction to the forecast on-time
chances on past days. Simulated chances can be too sure, for example
because real controllers recover late aircraft in ways the model does not
know; the correction removes that bias without changing the simulation.
simulsi aviation calibrate --write-ops ops.yaml --recalibrate fits it
along with the delay model.
backtest forecasts each past day from its schedule alone and scores the
forecasts:
- Reliability table: do flights given a 70% on-time chance arrive on time about 70% of the time?
- Brier score, against always predicting the average on-time rate.
- Delay-quantile coverage: about 80% of departures should be at or below the predicted p80.
- OTP error per day.
- Live scoring with
live_at=[...]: it also re-forecasts from the live state at those times and scores the flights still to depart.
See the BTS case study for full runs on real data: one month in detail and three carriers over four months.
from simulsi.aviation import Schedule, OpsConfig, DelayModel, History, calibrate, backtest
s, rules = Schedule.synthetic(), OpsConfig(runway_rate={"HUB": 30})
history = History.simulated(s, rules.replace(delays=DelayModel(prob=0.3, mean=35)), days=10)
model = calibrate(history, rules, iterations=3, replications=4, max_days=3)
print(model.prob, model.mean)
result = backtest(history, rules.replace(delays=model), replications=20, dates=history.dates[:2])
print(result.coverage, round(result.brier, 3))
A day-ahead forecast made from the schedule alone carries little information about which flights will be late. Its Brier skill is therefore modest, even when it is well calibrated. The value grows during the day, once the live state is known.
Machine-learning predictions. If you have a model that predicts a
flight's departure delay (weather, load factor, history), pass it as
DelayModel(predictor=fn). Here fn(flight) returns the expected primary
delay in minutes. The simulation samples around that prediction and
propagates it through aircraft, crews, runways and connections, which a
per-flight ML model cannot do on its own.
Command line¶
simulsi aviation example DIR # schedule, connections, ops.yaml, live status, history
simulsi aviation forecast schedule.csv -c connections.csv --ops ops.yaml [--weather "HUB 15:00-18:00 0.4 p=0.6"]
simulsi aviation forecast ... --status status.csv --now 12:00 [--action "cancel F1 F2"] [-o forecast.csv]
simulsi aviation watch schedule.csv --ops ops.yaml --feed https://ops.example/status.json --every 300 [-o latest.json]
simulsi aviation whatif ... --option "cancel=cancel F100 F101" --option "swap=swap T01 T02 12:00"
simulsi aviation recover ... [--status status.csv --now 12:00]
simulsi aviation reserves ... --spares 0,1,2 --standby 0,1,2
simulsi aviation buffers ... --budget 60 -o padded.csv
simulsi aviation impact schedule.csv changed.csv ...
simulsi aviation calibrate history.csv --ops ops.yaml -o delays.yaml
simulsi aviation backtest history.csv --ops ops.yaml --delays delays.yaml
simulsi aviation history bts.csv --carrier AS [--date 2026-06-21 -o day.csv --status-at 17:00 --status-out st.csv]
simulsi aviation calibrate bts.csv --carrier AS --days 20 --fit-turns --write-ops as_ops.yaml [--recalibrate]
simulsi aviation backtest bts.csv --carrier AS --skip-days 20 --ops as_ops.yaml --live-at 17:00
simulsi aviation turnaround [--team clean=1]
simulsi aviation mct | overbooking --seats 180 --show-rate 0.92 | checkin schedule.csv --airport HUB
Add --json for machine-readable output.
Limits¶
These are deliberate simplifications. Each could be refined if the data supports it:
- Distances. Block time stands in for distance, for example in the
EU261 bands. (
simulsi.spatial.Network.from_coordinatescan give great-circle distances when you have airport coordinates.) - Crew rules. Duty is checked against a single limit; there is no rest-rule engine. The model simulates one day at a time, so minimum rest between duties, which spans nights, is outside it.
- Swaps. Swaps of rotations between tails are only proposed at the
same airport. Spares can be ferried (
spare_ferry_minutes), but the ferry is a fixed time, not a scheduled flight. - Airspace and holding. Runway capacity is a rate per airport; there is no airspace or holding-stack model.
- Rebooking. Passengers are re-accommodated on direct flights only, not on multi-leg itineraries or other airlines.
- Storms. Mass cancellations ahead of a storm happen only if the
storm is given as a weather event with
c=; the model has no weather forecast of its own (see the case study).