Investigate Flight Delays with Python
Analyze flight disruptions and write an evidence-based operations brief.
Introduction
30 Second Summary
A late flight can look like a single number on a dashboard. That number can hide the journeys that caused the most disruption.
In this project, you will build a local flight-operations investigator in Python from official U.S. flight records. You will expose the outcomes hidden by a naive average before turning the corrected evidence into a recommendation for an airline operations team.
What You'll Build
One command against the August 2026 archive will expose the missing outcomes before writing a ranked brief you can preview in Visual Studio Code.
By the end of this project, you'll have:
- A reproducible analyzer that streams every August 2026 flight record directly from the official ZIP archive.
- Side-by-side evidence showing whether airport priority changes once late arrivals, cancellations, plus diversions all count as disruptions.
- A stakeholder-ready Markdown brief that identifies one operational investigation target. Its evidence covers airport performance, departure windows, reported delay categories, plus route hotspots.
- Secret Mission: Re-run the investigation on July 2026 to test whether the August recommendation survives another month.
Are there any prerequisites?
You should be comfortable building Python applications on Windows in Visual Studio Code.
You need internet access for one official download from Bureau of Transportation Statistics TranStats with no account or paid service required.
Before We Start
Before you inspect any flight data, lock in the operational decision this investigation must support. Your decision will identify the first airport operations team to investigate using a volume-aware disruption rate, departure-time patterns, reported delay-category minutes, and high-volume routes without claiming causal proof.
Set Up the Flight Investigation
Your airport recommendation needs every later metric to read the same untouched source data. A known workspace prevents downloaded files from becoming mixed with generated reports.
In this step, you will prepare a workspace in Visual Studio Code for the official Bureau of Transportation Statistics TranStats archive. You will also confirm that Python 3 can run the investigation.
In this step, get ready to:
- Prepare the flight-delay-investigation workspace with a data subfolder.
- Verify the Python 3 runtime in an integrated PowerShell terminal.
- Download the August 2026 BTS archive without extracting it.
Create the project workspace
A dedicated workspace gives your source data a predictable home. The data subfolder will hold the original archive throughout the investigation.
- Press Windows+E to open File Explorer.
- Select Desktop in the left navigation pane.
- Press Ctrl+Shift+N to create a folder.
- Type flight-delay-investigation as the folder name.
- Press Enter to save the name.
You will see the flight-delay-investigation folder on your Desktop.
- Open the flight-delay-investigation folder.
- Press Ctrl+Shift+N to create its source-data subfolder.
- Type data as the subfolder name.
- Press Enter to save the name.
File Explorer now shows an empty data folder inside flight-delay-investigation.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select File from the menu bar.
- Select Open Folder....
- Select the flight-delay-investigation folder on your Desktop.
- Select Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
Good start. The Visual Studio Code Explorer now shows flight-delay-investigation with its data subfolder.
Verify the Python runtime
The analyzer will use Python's standard library to read the archive directly. This keeps the setup reproducible without installing packages.
- Select Terminal from the Visual Studio Code menu bar.
- Select New Terminal.
- Confirm that the terminal tab is labeled PowerShell.
- Select the launch profile dropdown if another shell is active.
- Select PowerShell from the available profiles.
- Verify the installed Python runtime by running this command:
python --version
What does this check prove?
The --version flag asks Python to print its version number. A version beginning with 3 confirms that the runtime can support this standard-library investigation.
You will see Python followed by a version beginning with 3. Your local runtime is ready to process the flight archive.
Python version missing?
- Check that the command begins with lowercase python.
- Restart Visual Studio Code if Python was installed while the editor was open.
Ask for help: My integrated PowerShell terminal is not showing a Python 3 version. Help me verify the installed runtime.
Download and verify the BTS archive
The official PREZIP directory publishes monthly flight-performance archives. Keeping flights.zip unextracted preserves one source file for every later comparison.
- Open the official TranStats PREZIP directory in your browser at https://transtats.bts.gov/PREZIP/.
- Press Ctrl+F to search the directory listing.
- Type On_Time_Marketing_Carrier_On_Time_Performance_Beginning_January_2018_2026_8.zip into the search field.
- Select the matching filename to download the archive.
- Select your Downloads folder if your browser asks where to save the file.
Your browser will show the completed August 2026 archive download. The file is approximately 32 MB.
- Return to File Explorer from earlier.
- Select Downloads in the left navigation pane.
- Select On_Time_Marketing_Carrier_On_Time_Performance_Beginning_January_2018_2026_8.zip.
- Select Cut on the File Explorer ribbon.
- Select Desktop in the left navigation pane.
- Open the flight-delay-investigation folder.
- Open the data subfolder.
- Select Paste on the File Explorer ribbon.
The original BTS archive is now inside flight-delay-investigation\data.
- Select the downloaded archive inside the data folder.
- Press F2 to rename it.
- Type flights.zip as the new filename.
- Press Enter to save the name.
File Explorer now shows flights.zip inside the data folder.
Before you inspect the archive, which file type do you expect Python to list inside it?
- Return to the integrated PowerShell terminal from earlier.
- List the archive contents without extracting them by running this command:
python -m zipfile -l data\flights.zip
What does this command prove?
- The -m zipfile argument runs Python's standard-library ZIP tool.
- The -l argument lists the members inside data\flights.zip.
- The listing confirms that Python can access the source archive without extracting it.
You will see an archive-member listing with at least one filename ending in .csv. The ZIP remains unextracted inside data.
That locks in your source data. Every later metric now starts from the same official monthly archive.
Archive check not working?
- Confirm that the Visual Studio Code Explorer shows data\flights.zip.
- Check that the downloaded file came from the exact August 2026 archive link.
- Repeat the download if the listing does not include a CSV member.
Ask for help: My ZIP listing does not show the expected CSV member. Help me check the archive path and download.
Your workspace now has a verified Python 3 runtime plus an untouched flight-data archive. Next, you will build the first analyzer that exposes the severe outcomes hidden by a naive average.
Expose the Flights the Average Hides
Your August archive is verified inside the project folder. Now Python can stream each CSV record directly from the ZIP archive.
A mean arrival delay can only use rows with a populated ArrDelay value. This first working analyzer reveals how many canceled or diverted flights disappear from that calculation.
In this step, get ready to:
- Create a standard-library analyzer that streams records from the unextracted archive.
- Validate the required flight fields before calculating any metrics.
- Rank the busiest origins by mean populated arrival delay.
Create the streaming reader
A streaming reader handles one row at a time. This keeps the investigation reproducible without extracting a second copy of the monthly dataset.
- In the Visual Studio Code Explorer sidebar, create analyze_flights.py inside flight-delay-investigation.
- Add the imports and data contract by copying this code into analyze_flights.py:
import csv
import sys
import zipfile
from collections import defaultdict
from io import TextIOWrapper
from pathlib import Path
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
CAUSE_FIELDS = {
"CarrierDelay": "Air carrier",
"WeatherDelay": "Extreme weather",
"NASDelay": "National Aviation System",
"SecurityDelay": "Security",
"LateAircraftDelay": "Late-arriving aircraft",
}
REQUIRED_FIELDS = {
"FlightDate",
"Origin",
"Dest",
"CRSDepTime",
"ArrDelay",
"Cancelled",
"Diverted",
}.union(CAUSE_FIELDS)
What does this foundation define?
- The imports provide ZIP access, text decoding, CSV parsing, grouped statistics, command-line arguments, and file paths.
- The CAUSE_FIELDS mapping names the five reported delay categories required by the investigation.
- The REQUIRED_FIELDS set defines the columns that must exist before the analyzer trusts the source file.
- Save analyze_flights.py.
You should see analyze_flights.py beside the data folder in the Explorer sidebar.
File missing from the Explorer sidebar?
Confirm that analyze_flights.py sits directly inside flight-delay-investigation. It should not be inside the data folder.
Ask for help: Help me verify where analyze_flights.py was created in my Visual Studio Code project.
The analyzer needs small helpers to parse blank numeric cells and normalize column names. It also needs a compact statistics container for counting flights with usable arrival delays.
- Add these helper functions below REQUIRED_FIELDS in analyze_flights.py:
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
}
def to_float(value):
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def normalize_row(raw_row):
row = {}
for key, value in raw_row.items():
clean_key = key.strip()
row[clean_key] = value.strip() if isinstance(value, str) else value
return row
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
How do these helpers protect the analysis?
- The to_float() helper returns None for blank or invalid numeric cells.
- The normalize_row() helper removes stray whitespace from field names and text values.
- The update_stats() helper counts every flight while adding only populated arrival-delay values to the mean.
The first runnable pass only proves that every record can be streamed from the archive. Later edits in this step preserve that reader while adding the misleading metric.
- Add the first version of main() below the helper functions:
def main():
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/flights.zip")
if not source.exists():
raise SystemExit("ZIP file not found: {}".format(source))
total = new_stats()
with zipfile.ZipFile(source) as archive:
csv_members = [
name for name in archive.namelist() if name.lower().endswith(".csv")
]
if not csv_members:
raise SystemExit("No CSV file was found inside {}".format(source))
with archive.open(csv_members[0]) as binary_file:
with TextIOWrapper(
binary_file,
encoding="utf-8-sig",
newline="",
) as text_file:
reader = csv.DictReader(text_file)
for raw_row in reader:
total["flights"] += 1
print("Processed {:,} flight records.".format(total["flights"]))
if __name__ == "__main__":
main()
What does the first pass prove?
The analyzer discovers the CSV member by its file extension. TextIOWrapper decodes the binary ZIP stream so csv.DictReader can read it as rows.
The loop increments one counter per row. A successful count proves that the full archive can be processed without extraction.
- Save analyze_flights.py.
Before you run the analyzer, do you expect it to process a handful of summary rows or a full month of individual flight records?
- Run the first streaming pass from the integrated PowerShell terminal with this command:
python analyze_flights.py data\flights.zip
What should you see?
You should see one line beginning with Processed followed by a nonzero flight-record count. That count confirms the reader traversed the CSV inside the archive.
Good progress. Your analyzer can now stream the real monthly source without creating an extracted CSV copy.
Did the first pass stop?
- Confirm that the terminal is still working inside flight-delay-investigation.
- Confirm that the source file remains at data\flights.zip.
- Check the indentation under both with statements if Python reports a syntax problem.
Ask for help: Help me debug why my Python analyzer cannot stream data\flights.zip.
Validate the fields and count hidden outcomes
A row count proves the archive is readable. A reliable investigation also checks that every field used by the metric exists before processing begins.
- In main(), find the line that creates reader.
- Add this validation immediately below that line:
headers = {
name.strip() for name in (reader.fieldnames or []) if name is not None
}
missing = REQUIRED_FIELDS - headers
if missing:
raise SystemExit(
"Required fields are missing: {}".format(
", ".join(sorted(missing))
)
)
What does the field check prevent?
The check compares the CSV headers with REQUIRED_FIELDS. The analyzer stops with the missing names if the source schema cannot support the investigation.
- Save analyze_flights.py.
- Run the analyzer again with this command:
python analyze_flights.py data\flights.zip
What did this run confirm?
You should see the processed-record line again. This successful run confirms that all required location, schedule, outcome, and delay-cause fields are present.
That schema check has passed. Your metric now starts from known fields instead of assumptions about the download.
Seeing missing fields?
Confirm that data\flights.zip is the Marketing Carrier archive named in the previous step. A different BTS archive can expose a different schema.
Ask for help: Help me compare my CSV headers with REQUIRED_FIELDS.
The next pass tracks populated arrival delays by origin. It also keeps a separate count of cancellations or diversions whose blank ArrDelay value prevents them from entering the mean.
- Find total = new_stats() inside main().
- Add these counters directly below it:
airports = defaultdict(new_stats)
excluded_severe_outcomes = 0
What do these counters hold?
The airports mapping creates a fresh statistics container for each origin code. The excluded_severe_outcomes counter records severe outcomes omitted from the naive mean.
- Inside the existing for raw_row in reader: loop, find this current body:
total["flights"] += 1
Why replace the current body?
The current line only counts rows. The replacement retains that count through update_stats() while capturing the fields needed for the naive comparison.
- Replace that line with this expanded loop body:
row = normalize_row(raw_row)
origin = row.get("Origin", "").strip() or "Unknown"
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
How does the loop expose the omission?
- Each row is normalized before its origin and numeric outcome fields are read.
- Canceled or diverted rows with no usable ArrDelay increment excluded_severe_outcomes.
- Populated arrival delays are accumulated for the full dataset and the row's origin airport.
- Save analyze_flights.py.
- Run the expanded streaming loop with this command:
python analyze_flights.py data\flights.zip
What should remain stable?
You should see the same processed-record count. This proves the expanded loop still reaches every row while building airport-level statistics.
Did the expanded loop fail?
- Check that every line from row = normalize_row(raw_row) through the second update_stats() call remains inside the row loop.
- Check that airports uses defaultdict(new_stats) before the archive is opened.
Ask for help: Help me fix the streaming loop in analyze_flights.py.
- Find the processed-record print() call near the end of main().
- Add this output directly below it:
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
Why print the excluded count?
The count turns a hidden denominator problem into visible evidence. It shows the exact number of severe outcomes that cannot contribute to a populated-value mean.
- Save analyze_flights.py.
Before you run this version, do you think the excluded severe-outcome count will be zero or nonzero?
- Reveal the hidden outcomes by running this command:
python analyze_flights.py data\flights.zip
What did the naive metric hide?
You should see a nonzero count of canceled or diverted records with blank ArrDelay values. Those flights are absent from any mean calculated only from populated arrival delays.
There is the intended shortfall. The average drops severe outcomes before the airport comparison even begins.
Rank the naive airport leader
Airport volume matters because a tiny origin can produce an unstable mean from very few flights. The naive ranking first selects the 20 busiest origins before comparing their populated arrival delays.
- Add these ranking helpers below update_stats() and above main():
def average_arrival_delay(stats):
if stats["arr_delay_count"] == 0:
return None
return stats["arr_delay_sum"] / stats["arr_delay_count"]
def busiest_keys(grouped_stats, pool_size):
ranked = sorted(
grouped_stats.items(),
key=lambda item: item[1]["flights"],
reverse=True,
)
return [key for key, _ in ranked[:pool_size]]
How does the naive ranking work?
- The average_arrival_delay() helper divides the populated delay total by its populated-value count.
- The busiest_keys() helper sorts groups by flight volume before returning the requested pool.
- Near the end of main(), find the two current output statements:
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
What is missing from this ending?
The analyzer currently proves that rows were processed and severe outcomes were excluded. It still needs to select the busiest origins before naming the mean-delay leader.
- Replace those output statements with this completed ranking and output section:
if total["flights"] == 0:
raise SystemExit("The CSV contained no flight records.")
busy_airports = busiest_keys(airports, BUSY_AIRPORT_POOL)
naive_airports = [
airport
for airport in busy_airports
if average_arrival_delay(airports[airport]) is not None
]
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
naive_leader = naive_airports[0]
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
print(
"Naive leader: {} ({:.1f} mean arrival-delay minutes)".format(
naive_leader,
average_arrival_delay(airports[naive_leader]),
)
)
What does the completed output establish?
The analyzer limits the comparison to the busiest origin airports. It then sorts those airports by their mean populated arrival delay.
The terminal presents that naive leader beside the count of severe outcomes the metric excluded. The contrast makes the metric's blind spot inspectable.
- Save analyze_flights.py.
Before the final run, do you expect the terminal to show an airport leader even though the excluded severe-outcome count is nonzero?
- Run the completed first-stage analyzer with this command:
python analyze_flights.py data\flights.zip
What should the final run show?
- You should see a processed-record count for the full monthly CSV.
- You should see a nonzero count of canceled or diverted records excluded because ArrDelay is blank.
- You should see a naive airport leader with its mean populated arrival-delay minutes.
You have exposed the metric's blind spot with real operational data. The naive leader is now evidence of an incomplete calculation instead of a recommendation you have to accept.
Missing the naive leader?
- Check that average_arrival_delay() appears above main().
- Check that the airports[origin] statistics are updated inside the row loop.
- Check that naive_leader is selected after naive_airports.sort().
Ask for help: Help me debug why my analyzer does not print the naive airport leader.
✔️ Awesome, I've got everything!
Great. Double check that analyze_flights.py is saved before moving on.
ⓧ I'd like to double check the full code
Compare your complete first-stage analyzer with this file.
import csv
import sys
import zipfile
from collections import defaultdict
from io import TextIOWrapper
from pathlib import Path
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
CAUSE_FIELDS = {
"CarrierDelay": "Air carrier",
"WeatherDelay": "Extreme weather",
"NASDelay": "National Aviation System",
"SecurityDelay": "Security",
"LateAircraftDelay": "Late-arriving aircraft",
}
REQUIRED_FIELDS = {
"FlightDate",
"Origin",
"Dest",
"CRSDepTime",
"ArrDelay",
"Cancelled",
"Diverted",
}.union(CAUSE_FIELDS)
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
}
def to_float(value):
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def normalize_row(raw_row):
row = {}
for key, value in raw_row.items():
clean_key = key.strip()
row[clean_key] = value.strip() if isinstance(value, str) else value
return row
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
def average_arrival_delay(stats):
if stats["arr_delay_count"] == 0:
return None
return stats["arr_delay_sum"] / stats["arr_delay_count"]
def busiest_keys(grouped_stats, pool_size):
ranked = sorted(
grouped_stats.items(),
key=lambda item: item[1]["flights"],
reverse=True,
)
return [key for key, _ in ranked[:pool_size]]
def main():
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/flights.zip")
if not source.exists():
raise SystemExit("ZIP file not found: {}".format(source))
total = new_stats()
airports = defaultdict(new_stats)
excluded_severe_outcomes = 0
with zipfile.ZipFile(source) as archive:
csv_members = [
name for name in archive.namelist() if name.lower().endswith(".csv")
]
if not csv_members:
raise SystemExit("No CSV file was found inside {}".format(source))
with archive.open(csv_members[0]) as binary_file:
with TextIOWrapper(
binary_file,
encoding="utf-8-sig",
newline="",
) as text_file:
reader = csv.DictReader(text_file)
headers = {
name.strip() for name in (reader.fieldnames or []) if name is not None
}
missing = REQUIRED_FIELDS - headers
if missing:
raise SystemExit(
"Required fields are missing: {}".format(
", ".join(sorted(missing))
)
)
for raw_row in reader:
row = normalize_row(raw_row)
origin = row.get("Origin", "").strip() or "Unknown"
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
if total["flights"] == 0:
raise SystemExit("The CSV contained no flight records.")
busy_airports = busiest_keys(airports, BUSY_AIRPORT_POOL)
naive_airports = [
airport
for airport in busy_airports
if average_arrival_delay(airports[airport]) is not None
]
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
naive_leader = naive_airports[0]
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
print(
"Naive leader: {} ({:.1f} mean arrival-delay minutes)".format(
naive_leader,
average_arrival_delay(airports[naive_leader]),
)
)
if __name__ == "__main__":
main()
What does the complete file contain?
This reference combines ZIP discovery, schema validation, streaming row parsing, airport grouping, excluded-outcome counting, and the naive ranking. Use it to compare structure and indentation with your saved file.
Your first ranking now shows exactly why the average falls short. Next, you will replace that incomplete view with a disruption rate that includes late arrivals, cancellations, and diversions.
Build a Fair Disruption Metric
Your first analyzer proved that populated arrival-delay values leave severe outcomes outside the ranking. An airport can therefore look better because some failed flights have no arrival delay to average.
The corrected operational KPI counts a flight as disrupted when it arrives at least 15 minutes late. Cancellations and diversions also count as disruptions.
In this step, get ready to:
- Track late arrivals across every airport's flights.
- Include cancellations and diversions in the disruption count.
- Compare the naive leader with the corrected disruption-rate leader.
Track every disruption outcome
BTS defines an on-time flight as one that arrives less than 15 minutes after its published arrival time. Your analyzer uses the complementary threshold to classify arrivals that are at least 15 minutes late.
- In analyze_flights.py, find the existing new_stats() function.
- Replace the entire function by pasting this code:
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
"late": 0,
"cancelled": 0,
"diverted": 0,
"disrupted": 0,
"cause_minutes": defaultdict(float),
}
What does this code do?
- The existing fields preserve each airport's flight volume and populated arrival-delay values.
- The late, cancelled, and diverted fields keep the outcomes separate for inspection.
- The disrupted field stores the combined count used by the corrected metric.
- The cause_minutes map preserves the delay-category fields already validated by the analyzer.
- Save analyze_flights.py.
- Switch back to the integrated PowerShell terminal from earlier.
- Confirm the expanded statistics dictionary keeps the analyzer working by running:
python analyze_flights.py data\flights.zip
What should you see?
You should still see the processed-record count. You should also see the naive leader and the excluded severe-outcome count.
This confirms the expanded dictionary remains compatible with the current streaming loop.
Seeing a dictionary error?
- Check that every key in new_stats() has a colon followed by its initial value.
- Check that the closing brace lines up with the return statement.
Ask for help: Help me debug the new_stats dictionary in my flight analyzer.
The new counters need a single rule that updates them consistently for every row. The rule also keeps one flight from disappearing when its arrival-delay value is blank.
- Find the existing update_stats() function in analyze_flights.py.
- Replace the entire function by pasting this code:
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
late = arr_delay is not None and arr_delay >= LATE_THRESHOLD_MINUTES
disrupted = late or cancelled or diverted
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
if late:
stats["late"] += 1
if cancelled:
stats["cancelled"] += 1
if diverted:
stats["diverted"] += 1
if disrupted:
stats["disrupted"] += 1
for field, minutes in cause_values.items():
stats["cause_minutes"][field] += minutes
How is a disruption classified?
- The late condition checks whether a populated arrival delay reaches LATE_THRESHOLD_MINUTES.
- The disrupted condition becomes true for a late arrival. It also becomes true for a cancellation or diversion.
- Each outcome receives its own counter before the combined disruption counter increases.
- Every source row still increases flights. This gives the corrected rate a complete denominator.
- Inside for raw_row in reader:, find the section beginning with arr_delay = to_float(row.get("ArrDelay")).
- Replace the outcome-parsing and statistics-update section by pasting this code:
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
What happens to each row?
- The Cancelled and Diverted indicators become Boolean values for the disruption rule.
- Negative cause-minute values are prevented from reducing an airport's accumulated minutes.
- The analyzer updates the overall totals once per row.
- The analyzer also updates the row's origin airport once per row.
- Save analyze_flights.py.
- Verify that every record passes through the expanded update rule by running:
python analyze_flights.py data\flights.zip
What does this run prove?
You should see the processed-record count again. The naive leader and excluded-outcome count should also print successfully.
The successful run proves that every airport now accumulates the complete set of outcome counters.
Seeing an argument error?
- Check that update_stats() accepts stats, arr_delay, cancelled, diverted, and cause_values.
- Check that both update_stats() calls pass those five values in the same order.
Ask for help: Help me align the update_stats function with its two calls.
Rank the busiest airports by disruption rate
A fair comparison needs both a numerator and a denominator. The numerator is the combined disruption count. The denominator is every flight from that origin.
- Find the existing average_arrival_delay() function in analyze_flights.py.
- Add these two helper functions directly below it:
def disruption_rate(stats):
if stats["flights"] == 0:
return 0.0
return stats["disrupted"] / stats["flights"]
def percentage(part, whole):
if whole == 0:
return "0.0%"
return "{:.1f}%".format(100 * part / whole)
Why use two helpers?
- The disruption_rate() helper returns a comparable fraction for sorting airports.
- The zero-flight guard prevents division by zero.
- The percentage() helper turns a count pair into a one-decimal percentage for the terminal.
- Find the existing naive_airports.sort(...) section near the bottom of main().
- Replace that sorting section and its current leader assignment by pasting this code:
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
corrected_airports = sorted(
busy_airports,
key=lambda airport: disruption_rate(airports[airport]),
reverse=True,
)
naive_leader = naive_airports[0]
corrected_leader = corrected_airports[0]
target_stats = airports[corrected_leader]
How does the corrected ranking work?
- The existing naive list still sorts the busiest origins by mean populated arrival delay.
- The corrected list sorts the same busy_airports pool by disruption_rate().
- The corrected_leader identifier selects the highest-rate airport from that pool.
- The target_stats reference provides the leader's counts for the terminal result.
- Save analyze_flights.py.
- Confirm the corrected ranking calculates without an exception by running:
python analyze_flights.py data\flights.zip
What should complete?
The existing terminal output should complete successfully. This confirms the analyzer can calculate both leaders from the same busiest-airport pool.
Seeing a sorting error?
- Check that corrected_airports sorts busy_airports.
- Check that the sorting key passes airports[airport] into disruption_rate().
Ask for help: Help me debug the corrected airport sorting logic.
Compare the two airport leaders
The final comparison makes the designed obstacle visible. One line shows the populated-delay result. The next shows the all-outcome operational priority.
- Find the existing Naive leader: print block near the bottom of main().
- Add this corrected-leader print block directly below it:
print(
"Corrected leader: {} ({} disrupted)".format(
corrected_leader,
percentage(target_stats["disrupted"], target_stats["flights"]),
)
)
What does this line reveal?
The output names the airport with the highest disruption rate among the 20 busiest origins. Its percentage includes late arrivals, cancellations, and diversions.
- Save analyze_flights.py.
Before you run the analyzer, do you expect the corrected leader to match the naive leader?
- Reveal the corrected operational priority by running:
python analyze_flights.py data\flights.zip
What should you see?
You should see the processed-record count followed by a nonzero excluded-outcome count. You should then see the naive airport leader.
The final line should name the corrected leader with a disruption percentage. This is the visible evidence that all three disruption outcomes now influence the priority.
You have turned the missing outcomes into a usable operational signal. The analyzer can now compare airport performance without hiding cancellations or diversions.
Missing the corrected result?
- Check that the corrected print block remains inside main() at the same indentation level as the naive print block.
- Check that target_stats uses airports[corrected_leader].
- Check that percentage() receives the disruption count before the flight count.
Ask for help: Help me find why the corrected leader is missing from my analyzer output.
✔️ Awesome, I've got everything!
Great. Double check that analyze_flights.py is saved before you continue.
ⓧ I'd like to double check the full code
Compare your cumulative analyze_flights.py file with this version.
import csv
import sys
import zipfile
from collections import defaultdict
from io import TextIOWrapper
from pathlib import Path
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
CAUSE_FIELDS = {
"CarrierDelay": "Air carrier",
"WeatherDelay": "Extreme weather",
"NASDelay": "National Aviation System",
"SecurityDelay": "Security",
"LateAircraftDelay": "Late-arriving aircraft",
}
REQUIRED_FIELDS = {
"FlightDate",
"Origin",
"Dest",
"CRSDepTime",
"ArrDelay",
"Cancelled",
"Diverted",
}.union(CAUSE_FIELDS)
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
"late": 0,
"cancelled": 0,
"diverted": 0,
"disrupted": 0,
"cause_minutes": defaultdict(float),
}
def to_float(value):
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def normalize_row(raw_row):
row = {}
for key, value in raw_row.items():
if key is None:
continue
clean_key = key.strip()
row[clean_key] = value.strip() if isinstance(value, str) else value
return row
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
late = arr_delay is not None and arr_delay >= LATE_THRESHOLD_MINUTES
disrupted = late or cancelled or diverted
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
if late:
stats["late"] += 1
if cancelled:
stats["cancelled"] += 1
if diverted:
stats["diverted"] += 1
if disrupted:
stats["disrupted"] += 1
for field, minutes in cause_values.items():
stats["cause_minutes"][field] += minutes
def average_arrival_delay(stats):
if stats["arr_delay_count"] == 0:
return None
return stats["arr_delay_sum"] / stats["arr_delay_count"]
def disruption_rate(stats):
if stats["flights"] == 0:
return 0.0
return stats["disrupted"] / stats["flights"]
def percentage(part, whole):
if whole == 0:
return "0.0%"
return "{:.1f}%".format(100 * part / whole)
def busiest_keys(grouped_stats, pool_size):
ranked = sorted(
grouped_stats.items(),
key=lambda item: item[1]["flights"],
reverse=True,
)
return [key for key, _ in ranked[:pool_size]]
def main():
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/flights.zip")
if not source.exists():
raise SystemExit("ZIP file not found: {}".format(source))
total = new_stats()
airports = defaultdict(new_stats)
excluded_severe_outcomes = 0
with zipfile.ZipFile(source) as archive:
csv_members = [
name for name in archive.namelist() if name.lower().endswith(".csv")
]
if not csv_members:
raise SystemExit("No CSV file was found inside {}".format(source))
with archive.open(csv_members[0]) as binary_file:
with TextIOWrapper(
binary_file,
encoding="utf-8-sig",
newline="",
) as text_file:
reader = csv.DictReader(text_file)
headers = {
name.strip() for name in (reader.fieldnames or []) if name is not None
}
missing = REQUIRED_FIELDS - headers
if missing:
raise SystemExit(
"Required fields are missing: {}".format(
", ".join(sorted(missing))
)
)
for raw_row in reader:
row = normalize_row(raw_row)
origin = row.get("Origin", "").strip() or "Unknown"
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
if total["flights"] == 0:
raise SystemExit("The CSV contained no flight records.")
busy_airports = busiest_keys(airports, BUSY_AIRPORT_POOL)
naive_airports = [
airport
for airport in busy_airports
if average_arrival_delay(airports[airport]) is not None
]
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
corrected_airports = sorted(
busy_airports,
key=lambda airport: disruption_rate(airports[airport]),
reverse=True,
)
naive_leader = naive_airports[0]
corrected_leader = corrected_airports[0]
target_stats = airports[corrected_leader]
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
print(
"Naive leader: {} ({:.1f} mean arrival-delay minutes)".format(
naive_leader,
average_arrival_delay(airports[naive_leader]),
)
)
print(
"Corrected leader: {} ({} disrupted)".format(
corrected_leader,
percentage(target_stats["disrupted"], target_stats["flights"]),
)
)
if __name__ == "__main__":
main()
What should match?
Your file should retain ZIP streaming and required-field validation. It should also contain the naive ranking plus the corrected all-outcome ranking.
Your busiest-airport comparison now includes every severe outcome in its operational priority. Next, you'll trace when disruption grows and where its impact concentrates.
Trace Where Disruption Propagates
Your corrected airport ranking now shows where disruption is concentrated. The ranking still leaves operations teams with a broad target because it does not show when pressure builds or where impact travels.
You will extend the Python analyzer across three operational dimensions. Scheduled departure windows reveal timing patterns. Reported delay categories narrow the diagnostic focus. High-volume routes show where disruption affects repeated passenger journeys.
In this step, get ready to:
- Classify scheduled departures into operational time windows.
- Accumulate reported delay minutes across five categories.
- Rank high-volume routes by their all-outcome disruption rate.
Collect time, cause, and route context
A useful operational investigation needs more context than an airport code. Each streamed row already contains a scheduled departure time, a destination, and five reported delay-minute fields.
How will the departure windows work?
The analyzer converts CRSDepTime from its local hhmm form into four time windows. Invalid or blank values enter the Unknown bucket so the streaming loop can continue safely.
The windows make the result easier to discuss with an operations team. A recommendation tied to morning departures is more actionable than a single monthly airport percentage.
- In analyze_flights.py, find the constants above CAUSE_FIELDS.
- Replace that constants section with this expanded pool configuration:
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
BUSY_ROUTE_POOL = 50
What does this configuration control?
- The 15 minute threshold keeps the disruption definition from the corrected airport metric.
- The airport pool keeps the comparison focused on the 20 busiest origins.
- The route pool limits the route ranking to the 50 highest-volume origin-destination pairs.
- Find the closing line of the REQUIRED_FIELDS definition.
- Add the ordered departure-window labels immediately below it:
TIME_ORDER = ["Early morning", "Morning", "Afternoon", "Evening", "Unknown"]
Why define a fixed order?
A fixed sequence keeps the time windows in chronological order when the analyzer builds later output. Dictionary insertion order cannot communicate the intended operational sequence by itself.
- Find the closing return row line in normalize_row.
- Add the departure classifier immediately below that function:
def departure_bucket(value):
number = to_float(value)
if number is None:
return "Unknown"
hour = int(number) // 100
if 0 <= hour < 6:
return "Early morning"
if hour < 12:
return "Morning"
if hour < 18:
return "Afternoon"
if hour <= 24:
return "Evening"
return "Unknown"
What does this function do?
- The function reuses to_float so blank or malformed values become Unknown.
- Integer division by 100 extracts the hour from the scheduled departure value.
- The ordered conditions classify each usable hour into one operational window.
- Save analyze_flights.py.
- Confirm the new helper loads with the existing analyzer by running:
python analyze_flights.py data\flights.zip
What should you see?
You should see the processed-record count followed by the excluded severe outcomes. You should also see the naive airport leader and the corrected airport leader.
Those results confirm that the new classifier loads without breaking the working airport analysis.
- Find the grouped-stat initializations near the start of main.
- Replace that initialization section with the expanded analysis state:
total = new_stats()
airports = defaultdict(new_stats)
routes = defaultdict(new_stats)
time_buckets = defaultdict(new_stats)
excluded_severe_outcomes = 0
first_date = None
last_date = None
What does this state hold?
- The routes dictionary creates one statistics record for each origin-destination pair.
- The time_buckets dictionary creates one statistics record for each departure window.
- The date variables retain the earliest and latest record dates found in the archive.
- Inside the streaming loop, find the lines that read Origin and parse ArrDelay.
- Replace that row-parsing section with the expanded version:
origin = row.get("Origin", "").strip() or "Unknown"
destination = row.get("Dest", "").strip() or "Unknown"
route = "{} to {}".format(origin, destination)
bucket = departure_bucket(row.get("CRSDepTime"))
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
How does each row gain operational context?
- The destination joins the origin to create a readable route such as an airport pair separated by to.
- The scheduled departure value passes through departure_bucket before any grouped statistics are updated.
- Each reported cause value becomes a nonnegative number. Blank values become 0.0 so they do not interrupt the monthly scan.
- Save analyze_flights.py.
- Check that the expanded row parsing still processes the archive by running:
python analyze_flights.py data\flights.zip
What did this check prove?
You should see the full record count followed by both airport leaders. Reaching those results proves that every row supplied usable route, time-window, and cause values to the loop.
- Find the existing calls that update total and airports[origin].
- Replace those calls so both groups receive the parsed cause values:
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
Why pass the cause values here?
The global statistics accumulate all reported cause minutes across the archive. Each airport receives its own cause totals so the corrected leader can later expose its largest recorded category.
- Add the route, time-window, and record-period updates immediately below the airport update:
update_stats(
routes[route],
arr_delay,
cancelled,
diverted,
cause_values,
)
update_stats(
time_buckets[bucket],
arr_delay,
cancelled,
diverted,
cause_values,
)
flight_date = row.get("FlightDate", "").strip()
if flight_date:
if first_date is None or flight_date < first_date:
first_date = flight_date
if last_date is None or flight_date > last_date:
last_date = flight_date
What does this aggregation add?
- Every route receives the same late, cancellation, diversion, disruption, and cause-minute measures used for airports.
- Every time window receives those measures without storing the individual flight rows in memory.
- The date comparisons retain the archive boundaries using two values instead of a separate list of dates.
- Save analyze_flights.py.
- Confirm the expanded streaming loop completes by running:
python analyze_flights.py data\flights.zip
What should this run confirm?
You should reach the two airport leader lines without a field or conversion error. The analyzer has now accumulated airport, route, time-window, and cause statistics during one pass through the compressed source.
Does the streaming loop stop early?
- Check that cause_values is created inside the for raw_row in reader loop before any update_stats call uses it.
- Check that the route and time-window updates use routes[route] and time_buckets[bucket] exactly.
- Compare the indentation of the new blocks with the existing airport update.
Ask for help: Help me debug the new streaming aggregation in analyze_flights.py.
Rank windows and high-volume routes
A route with a tiny sample can produce an extreme rate by chance. The analyzer first selects the 50 busiest routes by flight count. It then ranks only that pool by disruption rate.
Why keep a volume guardrail?
Volume screening keeps repeated operational exposure in the comparison. The final ranking still uses disruption rate, so busy routes remain comparable even when their flight totals differ.
- Find the completed corrected_airports ranking near the bottom of main.
- Add the busy-route screening and corrected route ranking immediately below it:
busy_routes = busiest_keys(routes, BUSY_ROUTE_POOL)
corrected_routes = sorted(
busy_routes,
key=lambda route: disruption_rate(routes[route]),
reverse=True,
)
How are the route hotspots selected?
- The busiest_keys helper selects route keys using total flight volume.
- The second sort orders that screened pool by disruption_rate from highest to lowest.
- Add the departure-window ranking immediately below the route ranking:
ranked_buckets = sorted(
[name for name in TIME_ORDER if name in time_buckets and name != "Unknown"],
key=lambda name: disruption_rate(time_buckets[name]),
reverse=True,
)
How is the leading window chosen?
The list keeps only populated operational windows. It excludes Unknown from the recommendation because missing or invalid schedule values do not describe a usable departure period.
The sort places the highest all-outcome disruption rate first. The first item becomes the diagnostic time priority.
- Find the existing leader assignments below the rankings.
- Replace that short section with the version that selects the leading departure window:
naive_leader = naive_airports[0]
corrected_leader = corrected_airports[0]
time_leader = ranked_buckets[0]
target_stats = airports[corrected_leader]
What do the leader variables represent?
- The naive_leader remains the busiest-pool airport with the highest populated mean arrival delay.
- The corrected_leader remains the busiest-pool airport with the highest all-outcome disruption rate.
- The time_leader points to the departure window with the highest disruption rate.
- Find the corrected leader print statement near the end of main.
- Add the departure-window result immediately below it:
print("Highest-disruption departure window: {}".format(time_leader))
Why print this result now?
The new line creates an immediate operational signal from the added grouping logic. You can now verify the scheduled-departure analysis before building the written brief.
Before you run the analyzer, which departure window do you expect to have the highest disruption rate?
- Save analyze_flights.py.
- Reveal the leading window by running:
python analyze_flights.py data\flights.zip
What should you see?
You should see a new final line beginning with Highest-disruption departure window:. The value should be one of the four named operational windows.
That result proves the analyzer calculated an all-outcome disruption rate for each populated scheduled-departure window.
Missing the departure-window result?
- Check that ranked_buckets is calculated after the streaming loop finishes.
- Check that time_leader uses the first item from ranked_buckets.
- Check that the new print statement remains inside main.
Ask for help: Help me find why my highest-disruption departure window is missing.
Identify the leading recorded cause
The corrected airport leader identifies where to investigate. Its accumulated cause minutes provide a reported category to examine first.
What can cause minutes support?
The five categories are reported operational groupings. Their accumulated minutes help prioritize a diagnostic review. They do not prove that one category caused every disruption at the airport.
- Find the existing target_stats = airports[corrected_leader] line.
- Replace that line with the complete cause-selection block:
target_stats = airports[corrected_leader]
target_cause_field = max(
CAUSE_FIELDS,
key=lambda field: target_stats["cause_minutes"][field],
)
target_cause_minutes = target_stats["cause_minutes"][target_cause_field]
target_cause_label = (
CAUSE_FIELDS[target_cause_field]
if target_cause_minutes > 0
else "No dominant recorded category"
)
How does the category selection work?
- The max call compares the corrected leader's accumulated minutes for every field in CAUSE_FIELDS.
- The selected field remains available as target_cause_field for later report generation.
- The label falls back to No dominant recorded category when every recorded cause total is zero.
Before the final run, do you expect the processed-record count and airport leaders to remain stable after adding these new groupings?
- Save analyze_flights.py.
- Verify the completed propagation analysis by running:
python analyze_flights.py data\flights.zip
What should the completed run show?
- You should see the processed flight-record count.
- You should see a nonzero count of canceled or diverted records excluded from the naive result.
- You should see the naive airport leader with its mean arrival-delay minutes.
- You should see the corrected airport leader with its disruption percentage.
- You should see the highest-disruption departure window on the final line.
You have narrowed a broad airport ranking into time, route, and reported-cause evidence. The analyzer can now support a specific diagnostic priority.
Does the final run fail?
- Check that CAUSE_FIELDS remains a dictionary so the selected field can map to a readable label.
- Check that every update_stats call receives cause_values as its final argument.
- Check that target_cause_field is calculated after corrected_leader and target_stats are defined.
Ask for help: Help me debug the completed disruption propagation analysis.
✔️ Awesome, I've got everything!
Great. Double check that analyze_flights.py is saved before you continue.
ⓧ I'd like to double check the full code
Compare your cumulative analyzer with this Step 4 version. It includes the airport metrics from earlier steps plus the new time, cause, route, and record-period analysis.
import csv
import sys
import zipfile
from collections import defaultdict
from io import TextIOWrapper
from pathlib import Path
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
BUSY_ROUTE_POOL = 50
CAUSE_FIELDS = {
"CarrierDelay": "Air carrier",
"WeatherDelay": "Extreme weather",
"NASDelay": "National Aviation System",
"SecurityDelay": "Security",
"LateAircraftDelay": "Late-arriving aircraft",
}
REQUIRED_FIELDS = {
"FlightDate",
"Origin",
"Dest",
"CRSDepTime",
"ArrDelay",
"Cancelled",
"Diverted",
}.union(CAUSE_FIELDS)
TIME_ORDER = ["Early morning", "Morning", "Afternoon", "Evening", "Unknown"]
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
"late": 0,
"cancelled": 0,
"diverted": 0,
"disrupted": 0,
"cause_minutes": defaultdict(float),
}
def to_float(value):
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def normalize_row(raw_row):
row = {}
for key, value in raw_row.items():
if key is None:
continue
clean_key = key.strip()
row[clean_key] = value.strip() if isinstance(value, str) else value
return row
def departure_bucket(value):
number = to_float(value)
if number is None:
return "Unknown"
hour = int(number) // 100
if 0 <= hour < 6:
return "Early morning"
if hour < 12:
return "Morning"
if hour < 18:
return "Afternoon"
if hour <= 24:
return "Evening"
return "Unknown"
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
late = arr_delay is not None and arr_delay >= LATE_THRESHOLD_MINUTES
disrupted = late or cancelled or diverted
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
if late:
stats["late"] += 1
if cancelled:
stats["cancelled"] += 1
if diverted:
stats["diverted"] += 1
if disrupted:
stats["disrupted"] += 1
for field, minutes in cause_values.items():
stats["cause_minutes"][field] += minutes
def average_arrival_delay(stats):
if stats["arr_delay_count"] == 0:
return None
return stats["arr_delay_sum"] / stats["arr_delay_count"]
def disruption_rate(stats):
if stats["flights"] == 0:
return 0.0
return stats["disrupted"] / stats["flights"]
def percentage(part, whole):
if whole == 0:
return "0.0%"
return "{:.1f}%".format(100 * part / whole)
def busiest_keys(grouped_stats, pool_size):
ranked = sorted(
grouped_stats.items(),
key=lambda item: item[1]["flights"],
reverse=True,
)
return [key for key, _ in ranked[:pool_size]]
def main():
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/flights.zip")
if not source.exists():
raise SystemExit("ZIP file not found: {}".format(source))
total = new_stats()
airports = defaultdict(new_stats)
routes = defaultdict(new_stats)
time_buckets = defaultdict(new_stats)
excluded_severe_outcomes = 0
first_date = None
last_date = None
with zipfile.ZipFile(source) as archive:
csv_members = [
name for name in archive.namelist() if name.lower().endswith(".csv")
]
if not csv_members:
raise SystemExit("No CSV file was found inside {}".format(source))
with archive.open(csv_members[0]) as binary_file:
with TextIOWrapper(
binary_file,
encoding="utf-8-sig",
newline="",
) as text_file:
reader = csv.DictReader(text_file)
headers = {
name.strip() for name in (reader.fieldnames or []) if name is not None
}
missing = REQUIRED_FIELDS - headers
if missing:
raise SystemExit(
"Required fields are missing: {}".format(
", ".join(sorted(missing))
)
)
for raw_row in reader:
row = normalize_row(raw_row)
origin = row.get("Origin", "").strip() or "Unknown"
destination = row.get("Dest", "").strip() or "Unknown"
route = "{} to {}".format(origin, destination)
bucket = departure_bucket(row.get("CRSDepTime"))
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
update_stats(
routes[route],
arr_delay,
cancelled,
diverted,
cause_values,
)
update_stats(
time_buckets[bucket],
arr_delay,
cancelled,
diverted,
cause_values,
)
flight_date = row.get("FlightDate", "").strip()
if flight_date:
if first_date is None or flight_date < first_date:
first_date = flight_date
if last_date is None or flight_date > last_date:
last_date = flight_date
if total["flights"] == 0:
raise SystemExit("The CSV contained no flight records.")
busy_airports = busiest_keys(airports, BUSY_AIRPORT_POOL)
naive_airports = [
airport
for airport in busy_airports
if average_arrival_delay(airports[airport]) is not None
]
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
corrected_airports = sorted(
busy_airports,
key=lambda airport: disruption_rate(airports[airport]),
reverse=True,
)
busy_routes = busiest_keys(routes, BUSY_ROUTE_POOL)
corrected_routes = sorted(
busy_routes,
key=lambda route: disruption_rate(routes[route]),
reverse=True,
)
ranked_buckets = sorted(
[name for name in TIME_ORDER if name in time_buckets and name != "Unknown"],
key=lambda name: disruption_rate(time_buckets[name]),
reverse=True,
)
naive_leader = naive_airports[0]
corrected_leader = corrected_airports[0]
time_leader = ranked_buckets[0]
target_stats = airports[corrected_leader]
target_cause_field = max(
CAUSE_FIELDS,
key=lambda field: target_stats["cause_minutes"][field],
)
target_cause_minutes = target_stats["cause_minutes"][target_cause_field]
target_cause_label = (
CAUSE_FIELDS[target_cause_field]
if target_cause_minutes > 0
else "No dominant recorded category"
)
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
print(
"Naive leader: {} ({:.1f} mean arrival-delay minutes)".format(
naive_leader,
average_arrival_delay(airports[naive_leader]),
)
)
print(
"Corrected leader: {} ({} disrupted)".format(
corrected_leader,
percentage(target_stats["disrupted"], target_stats["flights"]),
)
)
print("Highest-disruption departure window: {}".format(time_leader))
if __name__ == "__main__":
main()
Your analyzer now traces disruption across airports, scheduled departure windows, reported categories, and busy routes. Next, you will turn those calculations into an operations brief that a stakeholder can inspect.
Deliver the Operations Brief
Your analyzer now identifies the corrected airport leader. It also traces disruption across departure windows, reported causes, and high-volume routes.
Analysis becomes forward-deployed work when a stakeholder can inspect the evidence and decide what to investigate next. This step turns your findings into a local Markdown brief with explicit caveats.
In this step, get ready to:
- Add Markdown table and relative-bar helpers.
- Generate a report with inspectable evidence tables.
- Preview the finished operations brief in Visual Studio Code.
Add the report formatting helpers
The operations brief uses the same table structure for several metrics. A shared row limit keeps each ranking focused on the ten highest-priority results.
- In analyze_flights.py, find BUSY_ROUTE_POOL = 50.
- Add the report row limit directly below that line by copying:
REPORT_LIMIT = 10
What does this value control?
The existing pool constants decide which airports and routes qualify for comparison. REPORT_LIMIT decides how many ranked entries appear in each report table.
- Find the end of busiest_keys().
- Add the Markdown table helper below that function by copying:
def markdown_table(headers, rows):
output = [
"| " + " | ".join(headers) + " |",
"| " + " | ".join(["---"] * len(headers)) + " |",
]
for row in rows:
output.append("| " + " | ".join(str(value) for value in row) + " |")
return output
What does this helper do?
- The headers values become the first table row.
- The separator row gives the Markdown renderer a column structure.
- Each result row becomes text before the helper joins its values.
- Save analyze_flights.py.
- Confirm the analyzer remains runnable by running:
python analyze_flights.py data\flights.zip
What does this check prove?
This command streams the same August archive after the first reporting changes. A completed run proves that the new constant and table helper have not interrupted the existing analysis.
You should still see the processed-record count, both airport leaders, and the highest-disruption departure window.
Seeing a syntax or name error?
- Check that REPORT_LIMIT = 10 sits with the other constants near the top of the file.
- Check that markdown_table() starts at the left edge with the other function definitions.
- Check that return output remains inside the function.
Ask for help: Help me compare my Markdown table helper with the project code.
- Return to analyze_flights.py.
- Add the relative-bar helper directly below markdown_table() by copying:
def bar(value, maximum, width=24):
if maximum <= 0:
return ""
filled = round(width * value / maximum)
return "#" * filled
What does this helper do?
- The helper scales each value against the largest value in its table.
- The width value limits the longest bar to 24 hash characters.
- The empty result prevents division when the maximum is zero.
- Save analyze_flights.py.
- Verify that both helpers leave the investigation runnable by running:
python analyze_flights.py data\flights.zip
What does this second check prove?
This run exercises the analyzer after both formatting helpers have been defined. The existing terminal findings should complete without a syntax error.
You should again see the processed-record count, the naive leader, the corrected leader, and the leading departure window.
Analyzer stopped after adding the bar?
- Check that bar() starts at the left edge below markdown_table().
- Check that filled is indented inside the function.
- Check the comparison operator in maximum <= 0.
Ask for help: Help me find the placement or indentation error in my bar helper.
Generate the stakeholder report
A decision brief must connect its recommendation to visible evidence. It must also show the hidden outcomes and caveats that limit what one month of observations can prove.
- In main(), locate the block that finishes assigning target_cause_label.
- Use the complete-file reference below to add the report-building code immediately after that block.
- Use the same reference to add the final report confirmation below the existing departure-window output.
How does the report support a decision?
The executive finding combines the corrected airport leader with the leading departure window. It also identifies the largest recorded delay category at that airport.
The recommendation frames those patterns as a diagnostic priority. The caveats stop one month of associations from becoming a causal claim.
✔️ Awesome, I've got everything!
Great. Double check that you saved analyze_flights.py before generating the report.
ⓧ I'd like to double check the full code
Compare your complete analyze_flights.py file with this reference. The report-building section begins directly below target_cause_label.
import csv
import sys
import zipfile
from collections import defaultdict
from io import TextIOWrapper
from pathlib import Path
LATE_THRESHOLD_MINUTES = 15
BUSY_AIRPORT_POOL = 20
BUSY_ROUTE_POOL = 50
REPORT_LIMIT = 10
CAUSE_FIELDS = {
"CarrierDelay": "Air carrier",
"WeatherDelay": "Extreme weather",
"NASDelay": "National Aviation System",
"SecurityDelay": "Security",
"LateAircraftDelay": "Late-arriving aircraft",
}
REQUIRED_FIELDS = {
"FlightDate",
"Origin",
"Dest",
"CRSDepTime",
"ArrDelay",
"Cancelled",
"Diverted",
}.union(CAUSE_FIELDS)
TIME_ORDER = ["Early morning", "Morning", "Afternoon", "Evening", "Unknown"]
def new_stats():
return {
"flights": 0,
"arr_delay_sum": 0.0,
"arr_delay_count": 0,
"late": 0,
"cancelled": 0,
"diverted": 0,
"disrupted": 0,
"cause_minutes": defaultdict(float),
}
def to_float(value):
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def normalize_row(raw_row):
row = {}
for key, value in raw_row.items():
if key is None:
continue
clean_key = key.strip()
row[clean_key] = value.strip() if isinstance(value, str) else value
return row
def departure_bucket(value):
number = to_float(value)
if number is None:
return "Unknown"
hour = int(number) // 100
if 0 <= hour < 6:
return "Early morning"
if hour < 12:
return "Morning"
if hour < 18:
return "Afternoon"
if hour <= 24:
return "Evening"
return "Unknown"
def update_stats(stats, arr_delay, cancelled, diverted, cause_values):
late = arr_delay is not None and arr_delay >= LATE_THRESHOLD_MINUTES
disrupted = late or cancelled or diverted
stats["flights"] += 1
if arr_delay is not None:
stats["arr_delay_sum"] += arr_delay
stats["arr_delay_count"] += 1
if late:
stats["late"] += 1
if cancelled:
stats["cancelled"] += 1
if diverted:
stats["diverted"] += 1
if disrupted:
stats["disrupted"] += 1
for field, minutes in cause_values.items():
stats["cause_minutes"][field] += minutes
def average_arrival_delay(stats):
if stats["arr_delay_count"] == 0:
return None
return stats["arr_delay_sum"] / stats["arr_delay_count"]
def disruption_rate(stats):
if stats["flights"] == 0:
return 0.0
return stats["disrupted"] / stats["flights"]
def percentage(part, whole):
if whole == 0:
return "0.0%"
return "{:.1f}%".format(100 * part / whole)
def busiest_keys(grouped_stats, pool_size):
ranked = sorted(
grouped_stats.items(),
key=lambda item: item[1]["flights"],
reverse=True,
)
return [key for key, _ in ranked[:pool_size]]
def markdown_table(headers, rows):
output = [
"| " + " | ".join(headers) + " |",
"| " + " | ".join(["---"] * len(headers)) + " |",
]
for row in rows:
output.append("| " + " | ".join(str(value) for value in row) + " |")
return output
def bar(value, maximum, width=24):
if maximum <= 0:
return ""
filled = round(width * value / maximum)
return "#" * filled
def main():
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/flights.zip")
if not source.exists():
raise SystemExit("ZIP file not found: {}".format(source))
total = new_stats()
airports = defaultdict(new_stats)
routes = defaultdict(new_stats)
time_buckets = defaultdict(new_stats)
excluded_severe_outcomes = 0
first_date = None
last_date = None
with zipfile.ZipFile(source) as archive:
csv_members = [
name for name in archive.namelist() if name.lower().endswith(".csv")
]
if not csv_members:
raise SystemExit("No CSV file was found inside {}".format(source))
with archive.open(csv_members[0]) as binary_file:
with TextIOWrapper(
binary_file,
encoding="utf-8-sig",
newline="",
) as text_file:
reader = csv.DictReader(text_file)
headers = {
name.strip() for name in (reader.fieldnames or []) if name is not None
}
missing = REQUIRED_FIELDS - headers
if missing:
raise SystemExit(
"Required fields are missing: {}".format(
", ".join(sorted(missing))
)
)
for raw_row in reader:
row = normalize_row(raw_row)
origin = row.get("Origin", "").strip() or "Unknown"
destination = row.get("Dest", "").strip() or "Unknown"
route = "{} to {}".format(origin, destination)
bucket = departure_bucket(row.get("CRSDepTime"))
arr_delay = to_float(row.get("ArrDelay"))
cancelled = to_float(row.get("Cancelled")) == 1
diverted = to_float(row.get("Diverted")) == 1
cause_values = {}
for field in CAUSE_FIELDS:
minutes = to_float(row.get(field))
cause_values[field] = max(minutes or 0.0, 0.0)
if (cancelled or diverted) and arr_delay is None:
excluded_severe_outcomes += 1
update_stats(total, arr_delay, cancelled, diverted, cause_values)
update_stats(
airports[origin],
arr_delay,
cancelled,
diverted,
cause_values,
)
update_stats(
routes[route],
arr_delay,
cancelled,
diverted,
cause_values,
)
update_stats(
time_buckets[bucket],
arr_delay,
cancelled,
diverted,
cause_values,
)
flight_date = row.get("FlightDate", "").strip()
if flight_date:
if first_date is None or flight_date < first_date:
first_date = flight_date
if last_date is None or flight_date > last_date:
last_date = flight_date
if total["flights"] == 0:
raise SystemExit("The CSV contained no flight records.")
busy_airports = busiest_keys(airports, BUSY_AIRPORT_POOL)
naive_airports = [
airport
for airport in busy_airports
if average_arrival_delay(airports[airport]) is not None
]
naive_airports.sort(
key=lambda airport: average_arrival_delay(airports[airport]),
reverse=True,
)
corrected_airports = sorted(
busy_airports,
key=lambda airport: disruption_rate(airports[airport]),
reverse=True,
)
busy_routes = busiest_keys(routes, BUSY_ROUTE_POOL)
corrected_routes = sorted(
busy_routes,
key=lambda route: disruption_rate(routes[route]),
reverse=True,
)
ranked_buckets = sorted(
[name for name in TIME_ORDER if name in time_buckets and name != "Unknown"],
key=lambda name: disruption_rate(time_buckets[name]),
reverse=True,
)
naive_leader = naive_airports[0]
corrected_leader = corrected_airports[0]
time_leader = ranked_buckets[0]
target_stats = airports[corrected_leader]
target_cause_field = max(
CAUSE_FIELDS,
key=lambda field: target_stats["cause_minutes"][field],
)
target_cause_minutes = target_stats["cause_minutes"][target_cause_field]
target_cause_label = (
CAUSE_FIELDS[target_cause_field]
if target_cause_minutes > 0
else "No dominant recorded category"
)
period = "{} to {}".format(first_date or "unknown", last_date or "unknown")
report = [
"# Flight Delay Chain Investigation",
"",
"Source file: `{}` ".format(source.name),
"Record period: {} ".format(period),
"Flights processed: {:,}".format(total["flights"]),
"",
"## Executive finding",
"",
(
"Among the 20 busiest origin airports, `{}` had the highest disruption "
"rate at {}. The largest recorded delay-minute category at that airport "
"was {}. The highest-disruption departure window across all flights was {}."
).format(
corrected_leader,
percentage(target_stats["disrupted"], target_stats["flights"]),
target_cause_label,
time_leader.lower(),
),
"",
(
"Recommendation: prioritize a diagnostic review at `{}` during the {} "
"window and investigate {} workflows first. Treat this as a prioritization "
"signal, not proof that the category caused every disrupted flight."
).format(
corrected_leader,
time_leader.lower(),
target_cause_label.lower(),
),
"",
"## The designed obstacle",
"",
(
"A ranking based only on populated `ArrDelay` values excluded {:,} canceled "
"or diverted records with blank arrival-delay values. The naive leader was "
"`{}`, while the all-outcome disruption leader was `{}`."
).format(excluded_severe_outcomes, naive_leader, corrected_leader),
"",
"## Naive ranking: mean populated arrival delay",
"",
]
naive_rows = []
for rank, airport in enumerate(naive_airports[:REPORT_LIMIT], start=1):
stats = airports[airport]
naive_rows.append(
[
rank,
airport,
"{:,}".format(stats["flights"]),
"{:.1f}".format(average_arrival_delay(stats)),
"{:,}".format(stats["cancelled"] + stats["diverted"]),
]
)
report.extend(
markdown_table(
["Rank", "Airport", "Flights", "Mean delay", "Canceled or diverted"],
naive_rows,
)
)
report.extend(["", "## Corrected ranking: all-outcome disruption rate", ""])
corrected_rows = []
maximum_airport_rate = disruption_rate(airports[corrected_airports[0]])
for rank, airport in enumerate(corrected_airports[:REPORT_LIMIT], start=1):
stats = airports[airport]
rate = disruption_rate(stats)
corrected_rows.append(
[
rank,
airport,
"{:,}".format(stats["flights"]),
percentage(stats["disrupted"], stats["flights"]),
stats["late"],
stats["cancelled"],
stats["diverted"],
bar(rate, maximum_airport_rate),
]
)
report.extend(
markdown_table(
[
"Rank",
"Airport",
"Flights",
"Disrupted",
"Late",
"Canceled",
"Diverted",
"Relative bar",
],
corrected_rows,
)
)
report.extend(["", "## Disruption by scheduled departure window", ""])
maximum_bucket_rate = max(
disruption_rate(time_buckets[name]) for name in ranked_buckets
)
bucket_rows = []
for name in TIME_ORDER:
if name not in time_buckets or name == "Unknown":
continue
stats = time_buckets[name]
rate = disruption_rate(stats)
bucket_rows.append(
[
name,
"{:,}".format(stats["flights"]),
percentage(stats["disrupted"], stats["flights"]),
bar(rate, maximum_bucket_rate),
]
)
report.extend(
markdown_table(
["Window", "Flights", "Disrupted", "Relative bar"],
bucket_rows,
)
)
report.extend(["", "## Reported delay minutes by category", ""])
ranked_causes = sorted(
CAUSE_FIELDS,
key=lambda field: total["cause_minutes"][field],
reverse=True,
)
total_cause_minutes = sum(total["cause_minutes"].values())
maximum_cause_minutes = max(total["cause_minutes"].values())
cause_rows = []
for field in ranked_causes:
minutes = total["cause_minutes"][field]
cause_rows.append(
[
CAUSE_FIELDS[field],
"{:,.0f}".format(minutes),
percentage(minutes, total_cause_minutes),
bar(minutes, maximum_cause_minutes),
]
)
report.extend(
markdown_table(
["Category", "Minutes", "Share", "Relative bar"],
cause_rows,
)
)
report.extend(["", "## High-volume route hotspots", ""])
maximum_route_rate = disruption_rate(routes[corrected_routes[0]])
route_rows = []
for rank, route in enumerate(corrected_routes[:REPORT_LIMIT], start=1):
stats = routes[route]
rate = disruption_rate(stats)
route_rows.append(
[
rank,
route,
"{:,}".format(stats["flights"]),
percentage(stats["disrupted"], stats["flights"]),
bar(rate, maximum_route_rate),
]
)
report.extend(
markdown_table(
["Rank", "Route", "Flights", "Disrupted", "Relative bar"],
route_rows,
)
)
report.extend(
[
"",
"## Caveats",
"",
"- This is one month of observations, so seasonality may change the ranking.",
"- The analysis identifies associations and priorities, not causal proof.",
"- Delay-cause minutes are broad reported categories and are not populated for every flight.",
"- The airport comparison is intentionally limited to the 20 busiest origins so very small samples do not dominate the recommendation.",
"",
]
)
report_path = Path("report.md")
report_path.write_text("\n".join(report), encoding="utf-8")
print("Processed {:,} flight records.".format(total["flights"]))
print(
"Naive result excluded {:,} canceled or diverted records with blank ArrDelay.".format(
excluded_severe_outcomes
)
)
print(
"Naive leader: {} ({:.1f} mean arrival-delay minutes)".format(
naive_leader,
average_arrival_delay(airports[naive_leader]),
)
)
print(
"Corrected leader: {} ({} disrupted)".format(
corrected_leader,
percentage(target_stats["disrupted"], target_stats["flights"]),
)
)
print("Highest-disruption departure window: {}".format(time_leader))
print("Wrote {}".format(report_path))
if __name__ == "__main__":
main()
What should the completed file contain?
This reference retains ZIP streaming and every existing metric. It adds the report rows, evidence tables, caveats, file-writing step, and final confirmation message.
Before you run the completed analyzer, do you expect the airport findings to change when only their presentation changes?
- Save analyze_flights.py.
- Generate the August operations brief by running:
python analyze_flights.py data\flights.zip
What does this run do?
The analyzer streams the August archive and recalculates every metric. It joins the report lines before writing report.md with UTF-8 text encoding.
You should see the processed-record count and the hidden-outcome count. You should also see both airport leaders, the leading departure window, and Wrote report.md.
- Confirm that report.md appears beside analyze_flights.py in the Visual Studio Code Explorer sidebar.
Report file missing or incomplete?
- Check that report_path.write_text remains inside main().
- Check that the output filename is exactly report.md.
- Scroll to the first terminal error if the analyzer stopped before its final message.
Ask for help: Help me trace why my analyzer did not write the complete report.
Preview the operations brief
A rendered preview makes the evidence hierarchy visible. The recommendation should appear as a diagnostic priority supported by tables and bounded by explicit caveats.
Before you preview the report, which section do you expect to reveal the weakness in the naive average most clearly?
- Select report.md in the Visual Studio Code Explorer sidebar.
- Press Ctrl+Shift+V to switch to the rendered preview.
You should see the executive finding near the top. You should also see its diagnostic recommendation, the designed obstacle, four evidence tables, the route table, and four caveats.
Why is the recommendation diagnostic?
The report shows where disruption is concentrated in this month of observations. Its delay categories describe reported associations without proving that one workflow caused every disrupted flight.
The recommendation therefore prioritizes an investigation target. The caveats protect the stakeholder from treating a screening signal as causal proof.
Preview not rendering correctly?
- Confirm that report.md is the active editor before pressing the preview shortcut.
- Run the analyzer again if the report ends before the caveats section.
- Compare markdown_table() with the full-file reference if the tables appear as plain text.
Ask for help: Help me diagnose my incomplete Markdown preview.
That completes the decision artifact. A stakeholder can now inspect the evidence, see the metric failure, and choose a focused operational investigation.
Secret mission
Test Whether the Finding Survives Another Month
August produced a diagnostic priority from one month of flight records. Test that finding against the official July 2026 archive with the same analyzer. Preserve both reports and document the evidence needed before changing an airline operating plan.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local investigation, pause it for later, or delete it entirely. The files have no ongoing cost.
Resources you used:
- Local project folder flight-delay-investigation.
- Local Python analyzer analyze_flights.py.
- Immutable source-data folder data.
- August 2026 source archive data\flights.zip.
- July 2026 source archive data\flights_july.zip.
- Most recently generated brief report.md.
- Preserved comparison reports report_august.md and report_july.md.
Keep everything running
No action is needed. Choose this if you want to revisit the investigation or compare more monthly archives.
- Keep flight-delay-investigation in its current location.
- Reuse analyze_flights.py with either archived month when you want to revisit the comparison.
- Preview report_august.md and report_july.md in Visual Studio Code when you want to compare the findings.
Pause - I'll come back to this later
Close the local workspace while preserving every archive, script, and report for later.
- Close Visual Studio Code.
- Leave flight-delay-investigation in its current location.
No analyzer or service continues running after the terminal command finishes. Your files remain ready for another comparison.
Delete - I don't want to use this again
Remove all local project files when you want a clean reset. The deletion affects only flight-delay-investigation on your computer.
Permanent deletion deserves a quick pause. Everything inside the folder disappears after confirmation.
- Close Visual Studio Code.
- Press the Windows key to open Windows search.
- Type File Explorer into Windows search.
- Press Enter to open File Explorer.
- Navigate to the location containing flight-delay-investigation.
- Select the flight-delay-investigation folder.
- Press Shift+Delete to request permanent deletion.
- Confirm the deletion if Windows shows a confirmation dialog.
You should no longer see flight-delay-investigation in its parent location. This confirms that both archives, the analyzer, and all three reports are gone.
Still See the Project Folder?
- Close any application that has a file from flight-delay-investigation open.
- Repeat the permanent deletion from the folder's parent location.
Ask for help if Windows still prevents the deletion: help me remove my local flight-delay-investigation folder safely.
Nice Work!
Nice Work!
Outstanding work! Your local Python investigator now turns monthly flight records into a defensible operations brief.
You learned how to:
- Built a streaming flight investigation that processes every record directly from an unextracted ZIP archive.
- Replaced an incomplete average with an all-outcome disruption KPI. The metric counts late arrivals. It counts cancellations. It counts diversions.
- Produced a stakeholder-ready operations brief in Markdown with four evidence views. You previewed the recommendation in Visual Studio Code with explicit caveats.
- Secret Mission: Tested the August recommendation against July 2026 data. Documented which operational findings persisted. Identified the additional evidence needed before changing an airline operating plan.
Ready to quiz yourself?