Skip to content

← Data and analysis

When Can You See the Himalaya from Kathmandu?

Topic 34 · Grades 6–8 · Patterns in real data

Python, from Grade 8 (lists, loops and if); the calendar on the page needs nothing.

What this builds on from class
In this unit 8 sections
  1. Try it
  2. How it works
  3. Program it
  4. What your program shows
  5. Going further still
  6. Challenges
  7. Questions
  8. Where to go next

On a clear morning, look north from a rooftop in the Kathmandu Valley and you may see a white wall of snow peaks floating above the hills. On most days you see nothing there at all: a pale, hazy sky where the mountains should be. They haven’t gone anywhere. Something between you and them has hidden them.

Looking north from a rooftop in Bhaktapur over the houses of the valley: a dark ridge, and above it a faint line of snow peaks under a blue sky, with a few small clouds.
Figure 34.1 Looking north from the roof of Science Playground in Katunje, Bhaktapur, at 10:41 on 23 October 2026. From left to right: Ganesh Himal, about 85 km away; the dark ridge of Shivapuri, about 17 km away; the Langtang range, about 67 km away; and Jugal Himal, with Dorje Lakpa, about 68 km away. Even on this clear day, the far peaks are pale with haze. Photo: Science Playground

So when should you look? You could ask your grandparents. Or you could ask the data. Two watchers in Kathmandu have been writing down the air and the sky for years: an air monitor at the US Embassy in Maharajgunj, every hour, and the weather observers at Tribhuvan International Airport, every half hour. Here are three years of what they saw, a square for each day.

Try it

A year of Kathmandu’s air and sky

Each row is a month, each square a day. Tap a day to read its numbers.

151015202531avgJan87Feb59Mar63Apr50May63Jun29Jul15Aug13Sep13Oct30Nov53Dec68
Show:
Year:

Tap a day to see its numbers.

Each square is a day, colored by its air. The number is the month’s average, in µg/m³ (lower is cleaner).

Before you look closely: in which months do you think Kathmandu’s air is cleanest?

Pick an answer to see why.

You need: for the calendar, nothing. For the programs, Python from Grade 8: lists, for loops and if.

Try it

Play with the calendar above.

  • a.On Air, which months are mostly green and yellow, and which are orange and red? Change the year. Is the pattern the same every year?
  • b.Now press Clouds. Which months are gray?
  • c.Press Both. A blue square is a day with clean air and a sky mostly free of thick cloud: a day to look north. Where do the blue squares crowd together, in each of the three years?

In the calendar, some squares are pale gray with a dashed edge, in every view. What do they mean?

Pick an answer to see why.

How it works

Two kinds of data

The air. The monitor at the US Embassy pumps air through a filter and measures PM2.5: the mass of tiny particles, smaller than 2.5 micrometers across, in each cubic meter of air. A grain of fine sand is about 100 micrometers; these particles are roughly 30 times thinner than a hair. They are smoke from fires and brick kilns, soot from engines, dust from roads. PM2.5 is written in micrograms per cubic meter, µg/m³: a microgram is a millionth of a gram. Lower is cleaner.

The US Environmental Protection Agency sorts PM2.5 into the categories that the calendar’s colors show (its numbers from 6 May 2024, for a day’s average):

A day’s PM2.5 (µg/m³) Category
0 to 9.0 Good
9.1 to 35.4 Moderate
35.5 to 55.4 Unhealthy for sensitive groups
55.5 to 125.4 Unhealthy
125.5 to 225.4 Very unhealthy
225.5 and more Hazardous

The same particles that harm lungs also scatter light. Light from Langtang crosses about 67 km of air to reach you. Every kilometer of hazy air turns a little of that light aside and adds a little glow of its own, so the far peaks fade into the color of the sky. On a hazy day, 67 km of haze is enough to hide them completely.

The sky. Every half hour, the airport’s observers send out a METAR, a weather report in a short code that every airport in the world uses. Here is one from Kathmandu, sent at 8:05 in the morning (Nepal time) on 15 July 2019:

METAR VNKT 150220Z VRB02KT 6000 FEW008 SCT030 BKN100 21/21 Q1007 NOSIG

VNKT is the airport’s code; 150220Z is the 15th at 02:20 world time (Nepal is 5 hours 45 minutes ahead); VRB02KT a light wind from no steady direction; 6000 means the observer could see 6,000 meters. Then the clouds, one layer at a time: how much of the sky each covers, in eighths, and its height in hundreds of feet. FEW is 1 or 2 eighths, SCT (scattered) 3 or 4, BKN (broken) 5 to 7, and OVC (overcast) all 8. VV means the sky can’t be seen at all, in fog. So this report says: a few clouds at 800 feet, scattered ones at 3,000 feet, and the sky mostly covered at 10,000 feet. 21/21 is the temperature and the dew point in °C, Q1007 the air pressure, and NOSIG “no big change expected”.

Which of these reports says the sky is mostly cloud?

Pick an answer to see why.

The rule our programs follow

For each day, a program on the office computer worked out two numbers from the raw data:

  • air: the average of the day’s 24 hourly PM2.5 readings (if at least 18 hours had one);
  • cloud: of the airport’s reports from 7 in the morning to 6 in the evening, the percent with a BKN, OVC or VV layer (if at least 15 of the 22 reports were there).

Then we chose a rule for a good day to look for the mountains: air of 35 or less (Good or Moderate) and cloud under 50% (thick cloud in fewer than half the day’s reports). It is a rule we chose, not a law of nature: in the challenges and in Going further still you change it and see what happens.

A day has air 28 and cloud 64. Is it a good day by our rule?

Pick an answer to see why.

Program it

Step 1: the cleanest month

All the days are in a file, kathmandu_air.py: one list days, with a row for each day, [year, month, day, air, cloud]. A missing reading is None, Python’s value for “nothing” (you met it in Grade 8, Functions).

This program adds up each month’s air, over all three years, and prints its average with a bar of #s (one # for every 2 µg/m³):

from kathmandu_air import days MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',          'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] print(len(days), 'days. The first:', days[0]) for m in range(1, 13):    total = 0    count = 0    for day in days:        if day[1] == m and day[3] is not None:            total = total + day[3]            count = count + 1    average = round(total / count)    print(MONTHS[m - 1], average, '#' * (average // 2))

Output

1097 days. The first: [2019, 1, 1, 97, 0]Jan 80 ########################################Feb 70 ###################################Mar 57 ############################Apr 67 #################################May 47 #######################Jun 25 ############Jul 12 ######Aug 10 #####Sep 13 ######Oct 26 #############Nov 50 #########################Dec 71 ###################################

day[1] is the row’s month and day[3] its air. is not None is how Python asks “is there a reading?”: line 12 skips the days with none, so a missing day doesn’t count as 0. (Counting it as 0 would make the month look cleaner than it was.) '#' * 5 is #####.

August is the cleanest month and January the worst, eight times dirtier. Most of the year the valley’s air is Unhealthy for sensitive groups or worse; only from June to October is it Moderate.

Step 2: count the good days

Now the program checks both numbers. [0] * 13 makes a list of 13 zeros: good[1] counts January’s good days, good[12] December’s (place 0 is left unused, so that months keep their own numbers). seen counts the days that had both readings.

from kathmandu_air import days MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',          'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']AIR_LIMIT = 35      # Good or Moderate on the US scaleCLOUD_LIMIT = 50    # thick cloud in fewer than half the reports good = [0] * 13     # good[1] for January ... good[12] for Decemberseen = [0] * 13     # days with both readingsfor day in days:    month = day[1]    air = day[3]    cloud = day[4]    if air is not None and cloud is not None:        seen[month] = seen[month] + 1        if air <= AIR_LIMIT and cloud < CLOUD_LIMIT:            good[month] = good[month] + 1 best = 1for m in range(1, 13):    print(MONTHS[m - 1], good[m], 'of', seen[m], '|' + '#' * good[m])    if good[m] > good[best]:        best = mprint('The most good days:', MONTHS[best - 1])

Output

Jan 0 of 77 |Feb 2 of 81 |##Mar 10 of 67 |##########Apr 6 of 79 |######May 7 of 79 |#######Jun 16 of 62 |################Jul 2 of 67 |##Aug 11 of 78 |###########Sep 13 of 80 |#############Oct 48 of 71 |################################################Nov 11 of 74 |###########Dec 0 of 75 |The most good days: Oct

The last loop prints each month’s bar and keeps the best so far, the pattern from Grade 8.

Step 3: the calendar, drawn by your program

A picture shows it faster than a list. show_grid(), from this page’s own pixels module, draws a list of rows, one square for each item (on a computer you would use a library such as Pillow). Each row here is a month, 31 squares long, with a blank row between the years:

from kathmandu_air import daysfrom pixels import show_grid picture = []for year in [2019, 2020, 2024]:    for m in range(1, 13):        row = ['white'] * 31        for day in days:            if day[0] == year and day[1] == m:                air = day[3]                cloud = day[4]                if air is None or cloud is None:                    row[day[2] - 1] = 'gainsboro'                elif air <= 35 and cloud < 50:                    row[day[2] - 1] = 'royalblue'                else:                    row[day[2] - 1] = 'wheat'        picture = picture + [row]    picture = picture + [['white'] * 31]show_grid(picture)

Picture

Three years of days in three blocks of twelve rows: mostly pale squares, gray gaps where readings are missing, and a cluster of blue squares in the tenth row, October, of every year, biggest in 2020.

picture + [row] makes a new list with row added at the end. day[2] - 1 is the square for that date: the 1st goes in place 0.

Step 4: the clearest hour

The haze also changes through each day. The file november2019.py has every hour of November 2019, a month when people often go out to look at the mountains: hours[0] is the 1st of November, and hours[0][9] its hour from 9 to 10 in the morning.

from november2019 import hours averages = [0] * 24for h in range(24):    total = 0    count = 0    for day in hours:        if day[h] is not None:            total = total + day[h]            count = count + 1    averages[h] = round(total / count) cleanest = 0haziest = 0for h in range(24):    print(h, 'to', h + 1, averages[h], '#' * (averages[h] // 2))    if averages[h] < averages[cleanest]:        cleanest = h    if averages[h] > averages[haziest]:        haziest = hprint('Cleanest: the hour from', cleanest, 'to', cleanest + 1)print('Haziest: the hour from', haziest, 'to', haziest + 1)

Output

0 to 1 50 #########################1 to 2 48 ########################2 to 3 47 #######################3 to 4 45 ######################4 to 5 45 ######################5 to 6 48 ########################6 to 7 58 #############################7 to 8 71 ###################################8 to 9 79 #######################################9 to 10 72 ####################################10 to 11 58 #############################11 to 12 49 ########################12 to 13 43 #####################13 to 14 36 ##################14 to 15 34 #################15 to 16 36 ##################16 to 17 40 ####################17 to 18 46 #######################18 to 19 52 ##########################19 to 20 56 ############################20 to 21 63 ###############################21 to 22 68 ##################################22 to 23 65 ################################23 to 24 56 ############################Cleanest: the hour from 14 to 15Haziest: the hour from 8 to 9

To see every hour of every day at once, color each one by its category. A function chooses the color, and each day becomes a row of 24 squares, midnight on the left:

from november2019 import hoursfrom pixels import show_grid def color(pm):    if pm is None:        return 'gainsboro'    if pm <= 9:        return '#5cb85c'     # Good    if pm <= 35.4:        return '#e6cf3c'     # Moderate    if pm <= 55.4:        return '#ef9a3c'     # Unhealthy for sensitive groups    if pm <= 125.4:        return '#d9534f'     # Unhealthy    return '#8e5a9e'         # Very unhealthy picture = []for day in hours:    row = []    for pm in day:        row = row + [color(pm)]    picture = picture + [row]show_grid(picture)

Picture

Thirty rows of 24 colored squares. In the first eighteen days, a red band down the morning columns, yellow in the early afternoon and orange at night; in the last twelve days, red almost all day, the haze of early winter arriving.

What your program shows

  • The cleanest air is not the best view. August has the cleanest air of the year, about 10 µg/m³ on average, but thick cloud on most days. December has the clearest skies of the year, but seven times the haze.
  • October is the month to look: 48 good days out of 71 with readings, in three years, about two days in three. No other month comes close; June is next, with 16 of 62. The answer stays October whether the air limit is 25, 35 or 50.
  • Within a day, the air is cleanest in the afternoon: in November 2019, about 34 µg/m³ from 2 to 3 p.m., against 79 from 8 to 9 in the morning. The red stripe in your picture is the same morning haze, day after day.

Nobody had to tell the program about monsoons, fires or winds. It counted, and the pattern came out of the counting. That is what data analysis is: the same few lines that find October here could find the busiest hour at a bus park, or the week a crop is most at risk of frost, from someone else’s records.

Why the pattern?

The months: in the monsoon, rain washes the smoke and dust out of the air, and the valley’s brick kilns stop work. In winter there is little rain, the kilns are burning, and people burn wood and waste; cold air settles on the valley floor under warmer air, like a lid on a pot, and holds the smoke in. From March to May the forest fires of the dry season add theirs. October sits between: the rain has cleaned the air, and the clouds have gone.

The hours: at night some of the smoke stays in layers above the valley floor. As the sun warms the ground in the morning, the air stirs and mixes it down again, just as traffic and cooking add more: that is the red stripe. From late morning a wind from the west blows through the valley and carries the haze away, so the early afternoon is cleanest. In the evening it builds up again.

What the data can’t tell you

Every data investigation has limits, and saying them honestly is part of the work:

  • One place for the air. The monitor is in Maharajgunj, in the north of Kathmandu. The air in Bhaktapur or on a hilltop can be cleaner or dirtier.
  • The airport’s sky, not the mountains’. The observers report the clouds over the airport. Clouds can sit on the Himalaya, 70 km away, while the valley’s sky is clear, and the other way round.
  • A strict rule. A thin, high cloud at 25,000 feet counts as BKN in a report, though the peaks may shine through it.
  • Readings not checked again. The embassy’s readings go out as the monitor makes them, and the US Environmental Protection Agency calls them “preliminary, unvalidated, and subject to change”. We dropped the monitor’s own fault code; a wrong reading that looks like a real one stays in.
  • Missing days. 192 of the 1,097 days have no air reading; we left them out rather than guess. If the monitor tended to break in one season, the counts for that season would be thinner.

Going further still

Change the rule. In Step 2, set AIR_LIMIT to 50, then to 25. Which months gain? (At 50, November and March climb a long way, but October stays first.)

Year by year. Add and day[0] == 2020 to the first if in Step 2. Why do you think October 2020 had 25 good days, about twice as many as October 2024 (13) and more than twice October 2019 (10)? (The COVID-19 lockdowns of 2020 are one guess, the weather another; the program can’t tell you which. What would you need to know to be sure?)

Will tomorrow be good? A day’s air and sky are a lot like the day before. Write a program that counts how often tomorrow is good when today is good, and how often tomorrow is good when today is not. Is “tomorrow will be like today” a useful forecast?

Challenges

★ Is it cloudy? (Python, from Grade 8)

Write is_cloudy(report), which gives back True if a METAR report says the sky is mostly cloud or hidden (it has BKN, OVC or VV in it) and False if not. 'BKN' in report is True when the letters BKN are somewhere in the string report.

def is_cloudy(report):    return False print(is_cloudy('METAR VNKT 150220Z VRB02KT 6000 FEW008 SCT030 BKN100 21/21 Q1007 NOSIG'))

Try it

★ Is it cloudy?

  1. The sky on 15 July 2019 is_cloudy('METAR VNKT 150220Z VRB02KT 6000 FEW008 SCT030 BKN100 21/21 Q1007 NOSIG') Should give True Yours —
  2. A clear winter morning is_cloudy('METAR VNKT 010020Z 00000KT 3000 BR FEW015 03/02 Q1023 NOSIG') Should give False Yours —
Tests passed
— / 7
Challenge
★ solved

Write your is_cloudy in the program above (Edit), then check it.

A test your function gets wrong, anything it prints, and any error appear here.

Hint
Answer

★★ The longest clear spell (Python, from Grade 8)

A year’s days are a string, one character a day: '1' a good day, '0' not, '.' no reading. Write longest_run(year), which gives back the most good days in a row. For '0110111.1' it is 3.

def longest_run(year):    # year is a string of '1', '0' and '.'    return 0 print(longest_run('0110111.1'))

Try it

★★ The longest clear spell

  1. The example longest_run('0110111.1') Should give 3 Yours —
  2. A missing day breaks a run longest_run('111.111') Should give 3 Yours —
Tests passed
— / 8
Challenge
★★ solved

Write your longest_run in the program above (Edit), then check it.

A test your function gets wrong, anything it prints, and any error appear here.

Hint
Answer

★★★ Plan a visit (Python, from Grade 8)

You can come to Kathmandu for k days in a row and want to see the mountains as often as possible. Given a year’s string (as in ★★) and k, write best_start(year, k), which gives back the place in the string (0 is 1 January) where k days in a row hold the most good days. If several places tie, give the earliest.

def best_start(year, k):    return 0 print(best_start('0010110110', 3))

Try it

★★★ Plan a visit

  1. The example best_start('0010110110', 3) Should give 2 Yours —
  2. A tie: the earliest best_start('1100110', 2) Should give 0 Yours —
Tests passed
— / 7
Challenge
★★★ solved

Write your best_start in the program above (Edit), then check it.

A test your function gets wrong, anything it prints, and any error appear here.

Hint
Another hint
Answer

Questions

What does PM2.5 measure?

Pick an answer to see why.

A program adds up a month’s daily air readings and divides by the number of days in the month, 31, though only 21 days had a reading. What goes wrong?

Pick an answer to see why.

a.

In one sentence each, why is July a poor month to look for the Himalaya, and why is January?

Answer
b.

A report reads METAR VNKT 230320Z VRB02KT 4000 BR FEW010 BKN100 16/14 Q1019 NOSIG. Does our rule count it as mostly cloud? What else does it tell you about the view?

Answer
c.

Your friend says: “The data proves that the COVID-19 lockdown made October 2020 clear.” What would you say?

Answer

Where to go next

Found a mistake? Please tell us.