The Wanda Walleye Project



Picture of Wanda Walleye on her trophy column and tackle box
Picture of Wanda Walleye on her trophy column and tackle box

I am listening. How can I help you?



When Is Best To Go Walkies


At 12:30 AM each night, Wanda downloads the weather forecasts from OpenWeather and NOAA, plus the NWS Air Quality Indicies, and uses them to find the best few times today to go for a walk. The “best” times are selected based on temperature, humidity, rain/snowfall, heat index, AQIs, and sunrise / sunset. Each 3-hour block of time gets a score, or is disqualified (for example, if it will be raining or too hot during that time block). Wanda generally favors earlier in the morning, all other things being equal. The top 3 times are then emailed via ‘mutt’ to me. This is a really handy service, and one I use daily.

Note that the scripts report "feels like" temperatures, but use maximum and minimum forecast temperatures when determining the best times. NOAA max temperatures override OpenForecast maxes (the NOAA temperatures tend to be more accurate). NOAA max temperatures are the maximum of the simple max temperature, heat index, and global wet bulb temperature, whichever is hottest.

Here is an example email. It was originally formatted to be easy for Wanda to read out loud. But it's a lot of text to read, and an email is easier to deal with, so it's a speech-formatted email.


Sunrise is at 5:21 A M
Sunset is at 8:43 P M
 
The best time to go for a walk will be: between 7 o clock A M and 10 o clock A M.
 The weather will be overcast clouds
 with temperatures around 69 degrees
 humidity around 88 percent
 precipitation chance around 0 percent
 and winds gusting to 6 miles per hour.
 Air quality index from ozone will be 24 or Good.
 A Q I from fine airborne particles will be 21 or Good.
 
Another good time to go for a walk will be: between 4 o clock P M and 7 o clock P M.
 The weather will be clear sky
 with temperatures around 75 degrees
 humidity around 67 percent
 precipitation chance around 0 percent
 and winds gusting to 16 miles per hour.
 Air quality index from ozone will be 31 or Good.
 A Q I from fine airborne particles will be 12 or Good.
 
A third good time to go for a walk will be: between 5 o clock A M and 7 o clock A M.
 The weather will be overcast clouds
 with temperatures around 71 degrees
 humidity around 85 percent
 precipitation chance around 0 percent
 and winds gusting to 3 miles per hour.
 Air quality index from ozone will be 24 or Good.
 A Q I from fine airborne particles will be 25 or Good.


Before diving into the code, please see About This Code.

Note that you will need the following:

Here is the crontab line that runs the when-walkies.sh shell script:



30 0 * * * cd /home/pipipi/billy-bin; /home/pipipi/billy-bin/when-walkies.sh >/home/pipipi/logs/when-walkies.log 2>&1



Here is the when-walkies.sh shell script that runs the Python scripts that do the real work.



# This script will figure out the best times to go for a walk today and
# will email the times to <your email address>.
#
# It requires the following, installed and hand-tested to both send and
# receive email, to work:
# - walkies-wanda.py, getaqis.py, getnws.py
# - A full email account (not forward or alias) on an IMAP mail server,
#   such as Tiger Technologies:
#   "wanda@youremailserver.net"
# - mutt installed on this computer via 'apt install mutt'. No Mail Transport
#   Agent is needed, 'mutt' can do the IMAP and SMTP connections itself.
# - ~/.muttrc configured as follows:
#
# set imap_user = "wanda@youremailserver.net"
# set imap_pass = "your-email-password"

# set smtp_url = "smtps://wanda@youremailserver.net@mail.youremailserver.net:465/"
# set smtp_pass = "your-email-password"
# set ssl_starttls = yes

# set from = "wanda@youremailserver.net"
# set realname = "Wanda Walleye"

# set folder = "imaps://mail.youremailserver.net:993"
# set spoolfile = "+INBOX"
# set postponed="+Drafts"

# set header_cache=~/.mutt/cache/headers
# set message_cachedir=~/.mutt/cache/bodies
# set certificate_file=~/.mutt/certificates

# set move = no

cd /home/pipipi/billy-bin
/usr/bin/python3 /home/pipipi/billy-bin/walkies-wanda.py 2>/home/pipipi/logs/walkies-wanda.log >/home/pipipi/temp/best-walkies.txt
/usr/bin/mutt -s 'Best times to go for a walk' -- you@jyouremailserver.net < /home/pipipi/temp/best-walkies.txt




Here is the top-level Python script, walkies-wanda.py. Note that I am no Pythonista, and that there are probably better ways to do many of the things in this script. This script originally just got its info from OpenWeather, but later on folded in the NOAA / NWS forecasts, which often proved to be more accurate and provided more details. The script kept the 3-hour forecast windows from OpenWeather because they seemed like good "time chunks" for choosing best walking times, not too specific, not too broad.


#!/usr/bin/env python3

import json
import sys
import os
import random
import argparse
import urllib.request
import time

# "getaquis.py" should be in the same directory as this file.
from getaqis import *

# "getnws.py" should be in the same directory as this file.
from getnws import *


"""
 walkies-wanda (A hack of walkies-mac tweaked to run more better on an Intel
 NUC running Ubuntu Linux. )

 Figure out when to go walkies, get in steps. Send an email with title:

 "Best times to go for a walk today"

 Dump the details of up to 3 good walk times into the body of the text.

 Run at 12:30AM vi cron job. As inspired by 'ansiweather' shell script(!),
 make an API call to OpenWeatherMap.org using urllib. Use json library to
 parse the big ol' response. This call is free using my API key.
 
 https://github.com/fcambus/ansiweather/blob/master/ansiweather

 https://openweathermap.org/forecast5

 See KeePass for example calls and my API key.

 Enhance the decision process for picking the best walk times by using
 more and better data from NOAA / NWS. Get Air Quality Index data
 hour-by-hour from NWS (not JSON; needs parsing), for example:

https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=ozone01&latitude=43.5198&longitude=-90.4051&region=CONUS&rundatetime=2024051712

 See getaqis.py for more details on how this is done.

 NWS has a rather exhaustive forecast JSON API for improved hour-by-hour
 weather forecasts with every detail you could want (except sunrise
 and sunset times, which are surely in there somewhere).

 https://www.weather.gov/documentation/services-web-api#/default/alerts_query

 The NWS API calls do not (yet) require an access key, but do require mapping
 ZIP codes or city names to Lat/Lon coords, and then doing a double-call,
 first to get the appropriate forecast link for the coords, and then
 to get the forecast itself.

 OpenWeather API will return a 3-hour x 5-day forecast including predicted
 temperatures, humidity, percent chance of precipitation, possibly 
 rain / snow in mm, wind speed / gust, plus times of sunrise and sunset.
 Everything needed to figure out best walkies time, in 3-hour resolution.

 If run at 12:30AM, should get 40 chunks of 3 hours each: 1AM to 4AM, 4AM to
 7AM, 7AM to 10AM, 10AM to 1PM, 1PM to 4PM, 4PM to 7PM, 7PM to 10PM, ...
 Sunrise will probably fall sometime in the second 4AM to 7AM chunk, and
 sunset in the 4PM to 7PM or 7PM to 10PM chunks. All the chunks after that
 are either after dark or tomorrow and onwards, we don't need them. The
 first chunk is useful for seeing if the ground will be wet/icy at sunrise.

 Factors to include:
 - Sunrise, sunset. Don't want to walk in the dark. Find the 3-hour
   chunks that include them, keep those chunks and the ones in between,
   toss the rest (tomorrow's chunks, etc.)

 - How hot, will it still be hot after 5? Like, are evening walks doable today?
   All other things being equal, pick the coolest chunk. Which is probably
   the earliest one.

 - Avoid busy sidewalk traffic times, 7:15 - 7:45AM, 3:15 - 3:45PM. Since this
   needs higher resolution than 3-hour chunks, maybe just find these times
   within their chunks and report only on the 1+ hour chunklets on either side
   of them.

 - Raining? Snowing? If the % of precipitation for a chunk is over say 50%,
   toss it. Also if either of the preceding 2 chunks is over 50%. This should
   also avoid early morning walks when it rained overnight. Don't want to 
   saturate my shoes and socks.

 - Icy sidewalks? If % of precipitation anytime earlier in day is over 50%
   AND temp is < 40F, might be icy. Recommend indoor walkies / bike today.

 - Summer gonna be hot. Note if "best" time is still over 80F, recommend
   big water chug beforehand, plenty of water ready after, think about
   taking water if possible. Apply sunscreen, wear performance clothes and
   hat with full brim. Go slow and easy.

   April - October: Recommend apply DEET, tick death spray, wear
   hat, long pants, long sleeves.

   December - February: Recommend use caution, err towards inside walks
   rather than risk slips. Check outside, check sidewalk with toes.

 Apply all above rules, write out a file, let Mac sync it up and email it
 to me if it is fresh.
"""


openWeatherURL = 'https://api.openweathermap.org/data/2.5/forecast?q=<YOUR-ZIPCODE-GOES-HERE>,US&&units=imperial&APPID=<YOUR-OPENWEATHER-API-CODE-GOES-HERE>'


def assignWalkiesScore(feelsTemp, humidity, precipitationChance, windGust, ozoneAQI, pm25AQI, startHour, endHour):
  """
  Calculate and assign a "good time to go for a walk" score for a period of
  time. Use the "feels like" temperature, humidity, chance of preciptation,
  max predicted wind gust speed, ozone AQI, and PM2.5 AQI to generate the
  score. Temperatures and AQIs are weighted more than the other values.

  These scores are assigned only for time periods that have not already
  been rejected due to, for example, temperatures being too hot.
  """
  if feelsTemp <= 70 and feelsTemp >= 40:
    tempScore = 4
  elif feelsTemp <= 85 and feelsTemp >= 30:
    tempScore = 2
  else:
    tempScore = 0
  if precipitationChance < 20:
    precipitationScore = 1
  else:
    precipitationScore = 0
  if humidity <= 50:
    humidityScore = 3
  elif humidity < 70:
    humidityScore = 2
  elif humidity < 80:
    humidityScore = 0
  elif humidity < 90:
    humidityScore = -1
  else:
    humidityScore = -2
  if windGust <= 10:
    windScore = 2
  elif windGust <= 20:
    windScore = 1
  else:
    windScore = 0
  if ozoneAQI <= 50:
    ozoneScore = 2
  else:
    ozoneScore = 0
  if pm25AQI <= 50:
    pm25Score = 2
  else:
    pm25Score = 0
  if startHour >= 16 or endHour <= 11:
    hourScore = 1
  else:
    hourScore = 0
  score = (tempScore * 2) + (precipitationScore * 2) + humidityScore +  \
    windScore + (ozoneScore * 2) + (pm25Score * 2) + hourScore
  return score


def walkiesScore(chunk):
  return chunk["score"]


def printChunkTimes(chunk):
  chunkStartTimeEpoch = chunk["dt"]
  chunkStartLocalTime = time.localtime(chunkStartTimeEpoch)
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3
  if chunkStartHour < sunriseHour:
    chunkStartHour = sunriseHour
  if chunkStartHour > 12:
    chunkStartHour = chunkStartHour - 12
    chunkStartAMPM = " P M"
  else:
    chunkStartAMPM = " A M"
  # chunkMinute = chunkStartLocalTime.tm_min
  chunkMinute =  "o clock"
  if chunkEndHour > sunsetHour:
    chunkEndHour = sunsetHour
  if chunkEndHour > 12:
    chunkEndHour = chunkEndHour - 12
    chunkEndAMPM = " P M"
  else:
    chunkEndAMPM = " A M"
  print(" between " + str(chunkStartHour) + " " + str(chunkMinute) +
    chunkStartAMPM, end='')
  print(" and " + str(chunkEndHour) + " " + str(chunkMinute) +
    chunkEndAMPM + ".")


def printChunk(chunk):
  """
  Print out the weather and AQI details for a period of time in a way
  that is friendly to being spoken aloud and to being sent via email.
  In other words, briefly and simply.
  """
  # print(chunk)
  printChunkTimes(chunk)
  print(" The weather will be " + str(chunk["weather"][0]["description"]))
  print(" with temperatures around " + str(round(chunk["main"]["feels_like"]))
    + " degrees")
  print(" humidity around " + str(round(chunk["main"]["humidity"]))
    + " percent")
  print(" precipitation chance around " + str(chunk["pop"] * 100) + " percent")
  print(" and winds gusting to " + str(round(chunk["wind"]["gust"])) +
    " miles per hour.")
  print(" Air quality index from ozone will be " + 
    str(chunk["ozone_aqi"]) + " or " +
    str(chunk["ozone_category"]) + ".")
  print(" A Q I from fine airborne particles will be " + 
    str(chunk["pm25_aqi"]) + " or " +
    str(chunk["pm25_category"]) + ".")
  print(" ")


def printChunkRejectionReason(chunk):
  # Before sunrise, after sunset; there are a ton of these, so just skip 'em.
  if chunk["rejection_code"] <= 2:
    return
  printChunkTimes(chunk)
  print("  will likely have " + chunk["rejection_text"])


def getChunkOzoneAQI(aqis, chunkStartLocalTime):
  """
  Find the maximum raw ozone value for a time chunk, and return
  it along with the corresponding ozone AQI and text category.
  Return -1 / -1 / "Unknown" if AQIs aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  ozoneValue = -1
  aqiValue = -1
  aqiCategory = "Unknown"
  if aqis is None:
    ozone = {"ozone": ozoneValue, "aqi": aqiValue, "category": aqiCategory}
    return ozone
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for ozoneAqi in aqis["ozone"]:
    if (ozoneAqi["year"] == chunkYear) and \
       (ozoneAqi["month"] == chunkMonth) and \
       (ozoneAqi["day"] == chunkDay) and \
       (ozoneAqi["hour"] >= chunkStartHour) and \
       (ozoneAqi["hour"] <= chunkEndHour) and \
       (ozoneAqi["ozone"] > ozoneValue):
         ozoneValue = ozoneAqi["ozone"]
         aqiValue = ozoneAqi["aqi_ozone"]
         aqiCategory = ozoneAqi["category_ozone"]
       
  ozone = {"ozone": ozoneValue, "aqi": aqiValue, "category": aqiCategory}
  return ozone


def getChunkPM25AQI(aqis, chunkStartLocalTime):
  """
  Find the maximum raw PM2.5 value for a time chunk, and return
  it along with the corresponding ozone AQI and text category.
  Return -1 / -1 / "Unknown" if AQIs aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  pm25Value = -1
  aqiValue = -1
  aqiCategory = "Unknown"
  if aqis is None:
    pm25 = {"pm25": pm25Value, "aqi": aqiValue, "category": aqiCategory}
    return pm25
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for pm25Aqi in aqis["pm25"]:
    if (pm25Aqi["year"] == chunkYear) and \
       (pm25Aqi["month"] == chunkMonth) and \
       (pm25Aqi["day"] == chunkDay) and \
       (pm25Aqi["hour"] >= chunkStartHour) and \
       (pm25Aqi["hour"] <= chunkEndHour) and \
       (pm25Aqi["pm25"] > pm25Value):
         pm25Value = pm25Aqi["pm25"]
         aqiValue = pm25Aqi["aqi_pm25"]
         aqiCategory = pm25Aqi["category_pm25"]
       
  pm25 = {"pm25": pm25Value, "aqi": aqiValue, "category": aqiCategory}
  return pm25


def getChunkNWSMinTemp(nws, chunkStartLocalTime):
  """
  Find the minimum NWS minimum-temperature value for a time chunk, and
  return it.
  Return 999 if NWS min temps  aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  mint = 999
  if nws is None:
    return mint
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsMinT in nws["minimumTemperature"]:
    if (nwsMinT["year"] == chunkYear) and \
       (nwsMinT["month"] == chunkMonth) and \
       (nwsMinT["day"] == chunkDay) and \
       (nwsMinT["hour"] >= chunkStartHour) and \
       (nwsMinT["hour"] <= chunkEndHour) and \
       (nwsMinT["mintF"] < mint):
         mint = nwsMinT["mintF"]
       
  return mint


def getChunkNWSMaxTemp(nws, chunkStartLocalTime):
  """
  Find the maximum NWS maximum-temperature value for a time chunk, and
  return it.
  Return -999 if NWS max temps  aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  maxt = -999
  if nws is None:
    return maxt
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsMaxT in nws["maximumTemperature"]:
    if (nwsMaxT["year"] == chunkYear) and \
       (nwsMaxT["month"] == chunkMonth) and \
       (nwsMaxT["day"] == chunkDay) and \
       (nwsMaxT["hour"] >= chunkStartHour) and \
       (nwsMaxT["hour"] <= chunkEndHour) and \
       (nwsMaxT["maxtF"] > maxt):
         maxt = nwsMaxT["maxtF"]
       
  return maxt


def getChunkNWSAppTemp(nws, chunkStartLocalTime):
  """
  Find the maximum NWS apparent-temperature value for a time chunk, and
  return it.
  Return -999 if NWW apparent temps  aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  appt = -999
  if nws is None:
    return appt
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsAppT in nws["apparentTemperature"]:
    if (nwsAppT["year"] == chunkYear) and \
       (nwsAppT["month"] == chunkMonth) and \
       (nwsAppT["day"] == chunkDay) and \
       (nwsAppT["hour"] >= chunkStartHour) and \
       (nwsAppT["hour"] <= chunkEndHour) and \
       (nwsAppT["apptF"] > appt):
         appt = nwsAppT["apptF"]
       
  return appt


def getChunkNWSHeatIndex(nws, chunkStartLocalTime):
  """
  Find the maximum NWS heat index temperature value for a time chunk, and
  return it.
  Return -999 if NWW apparent temps  aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  hi = -999
  if nws is None:
    return hi
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsHI in nws["heatIndex"]:
    if (nwsHI["year"] == chunkYear) and \
       (nwsHI["month"] == chunkMonth) and \
       (nwsHI["day"] == chunkDay) and \
       (nwsHI["hour"] >= chunkStartHour) and \
       (nwsHI["hour"] <= chunkEndHour) and \
       (nwsHI["heatIndexF"] > hi):
         hi = nwsHI["heatIndexF"]
       
  return hi


def getChunkNWSWetBulbGlobeTemp(nws, chunkStartLocalTime):
  """
  Find the maximum NWS wet bulb globe temperature value for a time chunk, and
  return it.
  Return -999 if NWS wet bulb globe temps  aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  wet = -999
  if nws is None:
    return wet
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsWet in nws["wetBulbGlobeTemperature"]:
    if (nwsWet["year"] == chunkYear) and \
       (nwsWet["month"] == chunkMonth) and \
       (nwsWet["day"] == chunkDay) and \
       (nwsWet["hour"] >= chunkStartHour) and \
       (nwsWet["hour"] <= chunkEndHour) and \
       (nwsWet["wgbtF"] > wet):
         wet = nwsWet["wgbtF"]
       
  return wet


def getChunkNWSProbabilityOfPrecipitation(nws, chunkStartLocalTime):
  """
  Find the maximum NWS probability of precipitation value for a time chunk, and
  return it.
  Return 0 if NWS POPs aren't available or if there
  aren't any that fall within the time period of this chunk.
  """
  pop = 0
  if nws is None:
    return pop
    
  chunkYear = chunkStartLocalTime.tm_year
  chunkMonth = chunkStartLocalTime.tm_mon
  chunkDay = chunkStartLocalTime.tm_mday
  chunkStartHour = chunkStartLocalTime.tm_hour
  chunkEndHour = chunkStartHour + 3

  for nwsPop in nws["probabilityOfPrecipitation"]:
    if (nwsPop["year"] == chunkYear) and \
       (nwsPop["month"] == chunkMonth) and \
       (nwsPop["day"] == chunkDay) and \
       (nwsPop["hour"] >= chunkStartHour) and \
       (nwsPop["hour"] <= chunkEndHour) and \
       (nwsPop["pop"] > pop):
         pop = nwsPop["pop"]
       
  return pop


######################### Main code.

with urllib.request.urlopen(openWeatherURL) as json_data:
  try:
    forecast = json.load(json_data)
  except ValueError:
    print ("Can't load JSON data from " + openWeatherURL)
    sys.exit()
  
  # print(forecast)
  status_code = forecast["cod"]
  # print(status_code)
  if status_code != "200":
    print("Bad JSON data load, code:" + str(status_code));
    sys.exit()

  # Get the latitude and longitude for your home or area.
  lat = <YOUR-LATITUDE-GOES-HERE>
  lon = <YOUR-LONGITUDE-GOES-HERE>

  aqis = getAQIs(lat, lon)
  # print(aqis)

  rawNWS = getNWSRawHourlyData(lat, lon)
  # print(rawNWS)
  nws = getNWSHourlyData(rawNWS)

  sunriseEpoch = forecast["city"]["sunrise"]
  sunsetEpoch = forecast["city"]["sunset"]
  # print(sunriseEpoch)
  sunriseLocalTime = time.localtime(sunriseEpoch)
  sunriseHour = sunriseLocalTime.tm_hour
  sunriseMinute = sunriseLocalTime.tm_min
  print("Sunrise is at " + str(sunriseHour) + ":" + str(sunriseMinute) + " A M")
  sunsetLocalTime = time.localtime(sunsetEpoch)
  sunsetHour = sunsetLocalTime.tm_hour
  sunsetMinute = sunsetLocalTime.tm_min
  print("Sunset is at " + str(sunsetHour - 12) + ":" + str(sunsetMinute)+" P M")
  print(" ")
  numChunks = forecast["cnt"]
  # print(numChunks)
  goodChunks = []
  likelyRainOrSnowToday = 0
  priorChunkLikelyWet = 0
  for chunk in forecast["list"]:
    chunkStartTimeEpoch = chunk["dt"]
    chunkStartLocalTime = time.localtime(chunkStartTimeEpoch)
    chunkStartHour = chunkStartLocalTime.tm_hour
    chunkMinute = chunkStartLocalTime.tm_min
    chunkEndHour = chunkStartHour + 3
    chunkMinTemp = chunk["main"]["temp_min"]
    chunkMaxTemp = chunk["main"]["temp_max"]
    chunkFeelsTemp = chunk["main"]["feels_like"]
    chunkHumidity = chunk["main"]["humidity"]
    chunkWeatherID = chunk["weather"][0]["id"]
    chunkWeatherMain = chunk["weather"][0]["main"]
    chunkPrecipitationChance = chunk["pop"] * 100
    chunkWindGust = chunk["wind"]["gust"]

    ozoneAQIValues = getChunkOzoneAQI(aqis, chunkStartLocalTime)
    chunk["ozone"] = ozoneAQIValues["ozone"]
    chunk["ozone_aqi"] = ozoneAQIValues["aqi"]
    chunk["ozone_category"] = ozoneAQIValues["category"]
    pm25AQIValues = getChunkPM25AQI(aqis, chunkStartLocalTime)
    chunk["pm25"] = pm25AQIValues["pm25"]
    chunk["pm25_aqi"] = pm25AQIValues["aqi"]
    chunk["pm25_category"] = pm25AQIValues["category"]

    chunkNWSMinTemp = getChunkNWSMinTemp(nws, chunkStartLocalTime)
    if chunkNWSMinTemp < chunkMinTemp:
      chunkMinTemp = chunkNWSMinTemp
    chunkNWSMaxTemp = getChunkNWSMaxTemp(nws, chunkStartLocalTime)
    if chunkNWSMaxTemp > chunkMaxTemp:
      chunkMaxTemp = chunkNWSMaxTemp
    chunkNWSHeatIndex = getChunkNWSHeatIndex(nws, chunkStartLocalTime)
    if chunkNWSHeatIndex > chunkMaxTemp:
      chunkMaxTemp = chunkNWSHeatIndex
    chunkNWSWetTemp = getChunkNWSWetBulbGlobeTemp(nws, chunkStartLocalTime)
    if chunkNWSWetTemp > chunkMaxTemp:
      chunkMaxTemp = chunkNWSWetTemp
    chunkNWSAppTemp = getChunkNWSAppTemp(nws, chunkStartLocalTime)
    if chunkNWSAppTemp > chunkFeelsTemp:
      chunkFeelsTemp = chunkNWSAppTemp
    chunkNWSPop = getChunkNWSProbabilityOfPrecipitation(nws,chunkStartLocalTime)
    if chunkNWSPop > chunkPrecipitationChance:
      chunkPrecipitationChance = chunkNWSPop
          
    # print("Chunk start: " + str(chunkStartHour) + ":" + str(chunkMinute))
    # print("        end: " + str(chunkEndHour) + ":" + str(chunkMinute))
    # print(" " + str(chunkMinTemp) + "/" + str(chunkMaxTemp))
    # print(" " + str(chunkHumidity) + "% " + str(chunkPrecipitationChance))
    # print(" " + str(chunkWeatherID) + "  " + str(chunkWeatherMain))

    # Toss all the time chunks that are problematic, not at all good
    # times for going for a walk.
    if chunkStartTimeEpoch > sunsetEpoch:
      chunk["rejection_code"] = 1
      chunk["rejection_text"] = "past sunset."
      continue
    if chunkPrecipitationChance > 30:
      if chunkMaxTemp < 35:
        chunk["rejection_text"] = "snow."
      else:
        chunk["rejection_text"] = "rain."
      chunk["rejection_code"] = 3
      likelyRainOrSnowToday = 1
      priorChunkLikelyWet = 1
      continue
    else:
      if priorChunkLikelyWet == 1:
        chunk["rejection_code"] = 4
        chunk["rejection_text"] = "grass and sidewalks likely wet."
        priorChunkLikelyWet = 0
        continue
      priorChunkLikelyWet = 0
    if chunkEndHour < sunriseHour:
      chunk["rejection_text"] = "before sunrise."
      chunk["rejection_code"] = 2
      continue
    if chunkMaxTemp > 90:
      chunk["rejection_code"] = 5
      chunk["rejection_text"] = "too hot, over 90F."
      continue
    if chunkWindGust > 30:
      chunk["rejection_code"] = 6
      chunk["rejection_text"] = "too windy, gusts over 30 MPH."
      continue
    if chunkMinTemp < 35 and likelyRainOrSnowToday:
      chunk["rejection_code"] = 7
      chunk["rejection_text"] = "slippery ice risk, under 35F."
      continue
    if chunk["ozone_aqi"] >= 100:
      chunk["rejection_code"] = 8
      chunk["rejection_text"] = "ozone AQI too high."
      continue
    if chunk["pm25_aqi"] >= 100:
      chunk["rejection_code"] = 9
      chunk["rejection_text"] = "PM25 AQI too high."
      continue
    if chunkMaxTemp > 75 and chunkHumidity > 85:
      chunk["rejection_code"] = 10
      chunk["rejection_text"] = "combo of temperature and humidity too high."
      continue
    chunk["rejection_text"] = "OK"
    chunk["rejection_code"] = 0

    # Assign a "how good a time to walk" score for every remaining good
    # time chunk, and save the chunk.
    chunk["score"] = assignWalkiesScore(chunkFeelsTemp, chunkHumidity,
      chunkPrecipitationChance, chunkWindGust,
      chunk["ozone_aqi"], chunk["pm25_aqi"], chunkStartHour, chunkEndHour)
    goodChunks.append(chunk)

  if len(goodChunks) < 1:
    print("Sorry but there are no good times for walkies today.")
    print("Please consider walking inside or riding a stationary bike.")
    print("Check a weather app for possible times that are good but brief.")
    print("")
    print("Times not so good for a walk include:")
    for chunk in forecast["list"]:
      printChunkRejectionReason(chunk)
    sys.exit()

  # Sort the chunks by how good their scores are, highest score first.
  goodChunks.sort(reverse = True, key = walkiesScore)

  print("The best time to go for a walk will be:", end='')
  printChunk(goodChunks[0])

  if len(goodChunks) > 1:
    print("Another good time to go for a walk will be:", end='')
    printChunk(goodChunks[1])

  if len(goodChunks) > 2:
    print("A third good time to go for a walk will be:", end='')
    printChunk(goodChunks[2])



Here is the script getaqis.py to get the NWS AQI info for a specific location.

NOTE:This script is hacky as heck. It uses an undocumented API call to get the AQI goods, one that is intended for NWS internal use to generate an interactive AQI map of the US. It could break at any time, but hopefully NWS will expose this info in a more official way someday.


"""
  Functions to read the NWS hacky AQI URL's data and parse it into 
  hourly date-hours and ozone and PM2.5 values.

  NOTE: The ozone and PM2.5 URL calls, while almost identical, will return
  different counts of values from each other, so the resulting value
  arrays can't simply be combined into one.

  ALSO NOTE: The AQI weather.gov server is SLOOOOW; these calls take several
  seconds each just to return a short list of date-hours and values.

  THIRD NOTE: There are other AQI data that could be looked at like dust
  and smoke, but it isn't clear how to interpret the values, or base any
  decisions off of them. Ozone and PM2.5 have pretty clear guidelines.

  BIG NOTE: The NWS URLs used are not advertised or publicly supported, and
  may break or otherwise change at any time without warning.

  The primary call is getAQIs(lat, lon), which returns None if something goes
  wrong, or hopefully a big ol' chonk o' info.
"""

import sys
import math
import urllib.request
from datetime import datetime, timedelta


# This URL gives the raw hourly AQI data. But it is hacky AF. It is basically
# an internal NWS URL with no public support for it, and may change or break
# at any time. But it provides what is needed.
#
# orig_aqiOzoneURL = 'https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=ozone01_bc&latitude=<YOUR-LAT-HERE>&longitude=<YOUR-LON-HERE>&region=CONUS&rundatetime=2024051712'
#
# orig_aqiPM25URL = 'https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=apm25h01_bc&latitude=<YOUR-LAT-HERE>&longitude=<YOUR-LON-HERE>&region=CONUS&rundatetime=2024051712'
#
# Base forecast AQI data URL is
# https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?
#  with:
#  dataset=aqprod& (always)
#  element=<element>& (see below)
#  latitude=<lat>&longitude=<lon>&
#  region=CONUS& (continental US)
#  rundatetime=<YYYYMMDDHH>
# element of 'ozone01' is ozone concentration in Parts Per Billion.
#  All returned data is hourly forecast for at least 3 days unless noted.
#  '..._bc' is bias-corrected version.
#  'ozone01_bc' is "bias-corrected ozone".
#  'ozone08' is also ozone, maybe weighted over 8 hours? Different than ozoneo1.
#  'mozone01' and 'mozone08', daily forecasts, not hourly.
#  'smokes01' is "near-surface smoke".
#  'smokec01' is "column-integrated smoke".
#  'dustsdc01' is "surface dust concentration".
#  'dustvdc01' is "Lowest 5-km Dust Concentration".
#  'apm25h01' is particulate matter <= 2.5 microns diameter.
#  'apm25h01_bc' is bias-corrected PM2.5.
# Values:
#   From: https://vlab.noaa.gov/web/osti-modeling/air-quality/faqs
#   Ozone: 0 - 54 ppb, good. It's a great day to be active outside.
#          55 - 70, moderate, sensitive ppl consider reducing long out activity
#          71 - 85, unhealthy for sensitive ppl. Reduce / sched in early AM.
#          86 - 105, unhealthy, sensitive avoid, others reduce / sched in AM.
#          106 - 200, very unhealthy, sensitive avoid all, others avoid hard.
#          201+, hazardous, everyone avoid ALL physical outdoor activity.
#   PM2.5: 0 - 12 ug/m3, good. It's a great day to be active outside.
#          12.1- 35.4, moderate, Sensitive ppl consider reduc outdoor activity.
#          35.5 - 55.4, unhealthy for sensitive ppl. Reduce / sched in early AM.
#          55.5 - 124.4, unhealthy, sensitive avoid, others reduc / sched in AM.
#          124.5 - 224.4, v. unhealthy, sensitive avoid all, others avoid hard.
#          224.5+, hazardous, everyone avoid ALL physical outdoor activity.
#
# NOTE: This is a very hacky URL; it is used internally by a NWS Javascript
# in-browser script to grab and pretty-plot AQI data. It might break
# without warning or any way to update / fix it.
# Example calls:
# https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=ozone01&latitude=<YOUR-LAT-HERE>&longitude=<YOUR-LON-HERE>&region=CONUS&rundatetime=2024051712
# ["ozone01",["2024051717","2024051718","2024051719","2024051720","2024051721","2024051722","2024051723","2024051800","2024051801","2024051802","2024051803","2024051804","2024051805","2024051806","2024051807","2024051808","2024051809","2024051810","2024051811","2024051812","2024051813","2024051814","2024051815","2024051816","2024051817","2024051818","2024051819","2024051820","2024051821","2024051822","2024051823","2024051900","2024051901","2024051902","2024051903","2024051904","2024051905","2024051906","2024051907","2024051908","2024051909","2024051910","2024051911","2024051912","2024051913","2024051914","2024051915","2024051916","2024051917","2024051918","2024051919","2024051920","2024051921","2024051922","2024051923","2024052000","2024052001","2024052002","2024052003","2024052004","2024052005","2024052006","2024052007","2024052008","2024052009","2024052010","2024052011","2024052012"],[43,45,46,45,45,44,40,35,31,28,27,27,26,24,23,22,22,22,22,21,21,27,33,37,39,40,40,41,41,37,35,32,28,26,27,32,32,30,29,30,29,29,29,27,29,34,39,44,46,47,48,46,44,43,42,37,32,30,32,31,29,29,31,34,32,28,26,28]]
# https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=apm25h01_bc&latitude=<YOUR-LAT-HERE>&longitude=<YOUR-LON-HERE>&region=CONUS&rundatetime=2024051712
# ["apm25h01_bc",["2024051713","2024051714","2024051715","2024051716","2024051717","2024051718","2024051719","2024051720","2024051721","2024051722","2024051723","2024051800","2024051801","2024051802","2024051803","2024051804","2024051805","2024051806","2024051807","2024051808","2024051809","2024051810","2024051811","2024051812","2024051813","2024051814","2024051815","2024051816","2024051817","2024051818","2024051819","2024051820","2024051821","2024051822","2024051823","2024051900","2024051901","2024051902","2024051903","2024051904","2024051905","2024051906","2024051907","2024051908","2024051909","2024051910","2024051911","2024051912","2024051913","2024051914","2024051915","2024051916","2024051917","2024051918","2024051919","2024051920","2024051921","2024051922","2024051923","2024052000","2024052001","2024052002","2024052003","2024052004","2024052005","2024052006","2024052007","2024052008","2024052009","2024052010","2024052011","2024052012"],[10,9,7,6,6,6,7,8,8,9,9,8,8,8,8,9,8,8,8,7,6,5,8,8,10,9,6,4,4,5,5,6,6,7,7,6,8,16,19,20,22,22,21,19,16,15,18,15,14,14,13,13,13,13,13,14,12,12,13,13,15,13,16,16,16,19,18,20,22,22,23,21]]


def computeOzoneAQI(ozonePPB):
  """
  Compute the AQI score for ozone from the raw ppb data.
  From pages 9 - 11 of:
  https://www.airnow.gov/sites/default/files/2020-05/aqi-technical-assistance-document-sept2018.pdf
  aqi = (iHigh - iLow) / (bpHigh - bpLow) * (ozonePPM - bpLow) + iLow
  NOTE: The table on page 10 does not define breakpoints / indicies for
  1-hour ozone readings under 0.125 (score 101, unhealthy for sensitive
  groups), so fill in with the 8-hour bp's and indicies, which is wrong-ish,
  but better than no score at all, which is useless most of the time.
  """

  # print(ozonePPB)
  ozonePPM = ozonePPB / 1000.0
  if (ozonePPM <= 0.054):
    bpLow = 0
    bpHigh = 0.054
    iLow = 0
    iHigh = 50
  elif (ozonePPM <= 0.70):
    bpLow = 0.055
    bpHigh = 0.070
    iLow = 51
    iHigh = 100
  elif (ozonePPM <= 0.164):
    bpLow = 0.071
    bpHigh = 0.164
    iLow = 101
    iHigh = 150
  elif (ozonePPM <= 0.204):
    bpLow = 0.165
    bpHigh = 0.204
    iLow = 151
    iHigh = 200
  elif (ozonePPM <= 0.404):
    bpLow = 0.205
    bpHigh = 0.404
    iLow = 201
    iHigh = 300
  elif (ozonePPM <= 0.504):
    bpLow = 0.405
    bpHigh = 0.504
    iLow = 301
    iHigh = 400
  else:
    bpLow = 0.505
    bpHigh = 0.604
    iLow = 401
    iHigh = 500
  aqi = (iHigh - iLow) / (bpHigh - bpLow) * (ozonePPM - bpLow) + iLow
  return(round(aqi))


def computePM25AQI(pm25):
  """
  Compute the AQI score for particulate matter <= 2.5 microns from the raw data.
  From pages 9 - 11 of:
  https://www.airnow.gov/sites/default/files/2020-05/aqi-technical-assistance-document-sept2018.pdf
  aqi = (iHigh - iLow) / (bpHigh - bpLow) * (pm25 - bpLow) + iLow
  """
  # print(pm25)
  if (pm25 <= 12.0):
    bpLow = 0
    bpHigh = 12.0
    iLow = 0
    iHigh = 50
  elif (pm25 <= 35.4):
    bpLow = 12.1
    bpHigh = 35.4
    iLow = 51
    iHigh = 100
  elif (pm25 <= 55.4):
    bpLow = 35.5
    bpHigh = 55.4
    iLow = 101
    iHigh = 150
  elif (pm25 <= 150.4):
    bpLow = 55.5
    bpHigh = 150.4
    iLow = 151
    iHigh = 200
  elif (pm25 <= 250.4):
    bpLow = 150.5 
    bpHigh = 250.4 
    iLow = 201
    iHigh = 300
  elif (pm25 <= 350.4):
    bpLow = 250.5 
    bpHigh = 350.4
    iLow = 301
    iHigh = 400
  else:
    bpLow = 350.5
    bpHigh = 500.4
    iLow = 401
    iHigh = 500
  aqi = (iHigh - iLow) / (bpHigh - bpLow) * (pm25 - bpLow) + iLow
  return(round(aqi))


def assignCategoryAQI(aqiScore):
  """
  Assign an AQI Category title based on the AQI score.
  """
  # print(aqiScore)
  if (aqiScore <= 50):
    return "Good"
  elif (aqiScore <= 100):
    return "Moderate"
  elif (aqiScore <= 150):
    return "Unhealthy for sensitive groups"
  elif (aqiScore <= 200):
    return "Unhealthy"
  elif (aqiScore <= 300):
    return "Very unhealthy"
  else:
    return "HAZARDOUS"


def getAQIs(lat, lon):
  """
  Get hourly ozone and PM2.5 values, return None
  if something goes wrong, otherwise return aqis
  where aqis["ozone"] includes a list of hourly ozone
  values and equivalent AQI values and categories:
  aqis["ozone"][0...]["year"], ["month"], ["day"], ["hour"],
  ["ozone"] in PPB, ["aqi_ozone"] AQI value, and
  ["category_ozone"] AQI category name.
  aqis["pm25"] is an equivalent list:
  aqis["pm25"][0...]["year"], ["month"], ["day"], ["hour"],
  ["pm25"] in ug/m3, ["aqi_pm25"] AQI value, and
  ["category_pm25"] AQI category name.
  """

  # These NWS AQI calls return a 500 error on calls anytime other than 12PM,
  # and 500 error for any date newer than yesterday. BUT they will return
  # a long list of values, including all of the ones for today, if called for
  # yesterday at 12PM. OK, whatever, fine.
  yesterday = datetime.now() - timedelta(1)
  runDateTime = yesterday.strftime('%Y%m%d') + '12'
  # runDateTime = '2024051812'
  # print(runDateTime)

  aqiOzoneURL = 'https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=ozone01_bc&latitude=' + str(lat) + '&longitude=' + str(lon) + '&region=CONUS&rundatetime=' + runDateTime
  aqiOzoneURL = str(aqiOzoneURL)
  # print(aqiOzoneURL)
  # print(orig_aqiOzoneURL)

  aqiPM25URL = 'https://airquality.weather.gov/aqtable/scripts/degrib_perioddata.php?dataset=aqprod&element=apm25h01_bc&latitude=' + str(lat) + '&longitude=' + str(lon) + '&region=CONUS&rundatetime=' + runDateTime
  aqiPM25URL = str(aqiPM25URL)
  # print(aqiPM25URL)
  # print(orig_aqiPM25URL)

  aqiOzoneFP = urllib.request.urlopen(aqiOzoneURL)

  # Read in the URL contents as one big string. It'll basically be a data
  # array of the form ["ozone01",["2024051511",...],[42,...]]
  aqiOzoneData = str(aqiOzoneFP.read())

  if len(aqiOzoneData) < 10:
    print("Failed to read data from AQI ozone URL.")
    return None

  aqiPM25FP = urllib.request.urlopen(aqiPM25URL)
  aqiPM25Data = str(aqiPM25FP.read())
  if len(aqiPM25Data) < 10:
    print("Failed to read data from AQI PM2.5 URL.")
    return None

  # First get rid of the double-quotes, square brackets, and 'b' chars.
  translationTable = str.maketrans('', '', '"[]b')
  aqiOzoneData = aqiOzoneData.translate(translationTable)
  aqiPM25Data = aqiPM25Data.translate(translationTable)
  # Now get rid of the single quotes.
  translationTable = str.maketrans("", "", "'")
  aqiOzoneData = aqiOzoneData.translate(translationTable)
  aqiPM25Data = aqiPM25Data.translate(translationTable)

  # Split the array into the title '"ozone01', the date-hours
  # '2024051511', '2024051512', ..., and the ozone values '42', '43', ...
  aqiOzoneRawValues = aqiOzoneData.split(',')
  aqiPM25RawValues = aqiPM25Data.split(',')
  # Get the count of date-hours and values.
  aqiOzoneRawCount = math.floor((len(aqiOzoneRawValues) - 1) / 2)
  aqiPM25RawCount = math.floor((len(aqiPM25RawValues) - 1) / 2)
  # print(aqiOzoneRawValues)
  # print(aqiOzoneRawCount)
  # print(aqiPM25RawValues)
  # print(aqiPM25RawCount)

  aqiOzoneValues = []
  for i in range(1, aqiOzoneRawCount + 1):
    dt = datetime.strptime(aqiOzoneRawValues[i], '%Y%m%d%H') 
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["ozone"] = float(aqiOzoneRawValues[i + aqiOzoneRawCount])
    value["aqi_ozone"] = computeOzoneAQI(value["ozone"])
    value["category_ozone"] = assignCategoryAQI(value["aqi_ozone"])
    aqiOzoneValues.append(value)
 
  aqiPM25Values = []
  for i in range(1, aqiPM25RawCount + 1):
    dt = datetime.strptime(aqiPM25RawValues[i], '%Y%m%d%H') 
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["pm25"] = float(aqiPM25RawValues[i + aqiPM25RawCount])
    value["aqi_pm25"] = computePM25AQI(value["pm25"])
    value["category_pm25"] = assignCategoryAQI(value["aqi_pm25"])
    aqiPM25Values.append(value)

  aqiValues = {"ozone": aqiOzoneValues, "pm25": aqiPM25Values}
  return aqiValues
 

# Test your location
# lat = <YOUR-LATITUDE>
# lon = <YOUR-LONGITUDE>
# aqis = getAQIs(lat, lon)
# print(aqis)


Here is the script getnws.py to get the firehose of NOAA / NWS weather forecast info. Only a small fraction of it is used for predicting the best walking time, but wow, there is a ton of info provided.


"""
  Get hourly weather data from NOAA / NWS API.

  https://www.weather.gov/documentation/services-web-api#/default/gridpoint

  https://api.weather.gov/

  This is a two-hop process:

  1. Feed a lat,lon pair into the "points" API, which will return
  lots of JSON info, including forecast URLs for 12 hour x 7 day,
  1 hour x 7 days, and raw data (which is also hourly) over 7 days.

  The "points" URL for your latitude and longitude:
  https://api.weather.gov/points/<YOUR-LATITUDE>,<YOUR-LONGITUDE>

  The raw data URL can be extracted from:
    pointsInfo["properties"]["forecastGridData"]

  Example:
  https://api.weather.gov/gridpoints/ARX/92,49

  2. Use the raw 1 hour x 7 days URL to get a big ol' firehose of weatherly
  JSON data.

  The grid data has '@context', 'id', 'type', 'geometry', all very nice,
  and the big boy, 'properties':

  data['properties']

  which has DOZENS of sub-arrays: 'temperature', 'dewpint', ... 'heatIndex',
  'wetBulbGlobeTemperature', 'probabilityOfPrecipitation', 'weather', ...

  data['properties']['heatIndex']

  with the meat in the 'values' part:

  data['properties']['heatIndex']['values']

  and the units in the 'uom' part:

  data['properties']['heatIndex']['uom'] = "wmoUnit:degC"

  Each of these will have it's own special length:

  len(data['properties']['heatIndex']['values']) = 98

  len(data['properties']['temperature']['values']) = 147

  And each entry in the 'values' array may or may not have a valid value:

  data['properties']['heatIndex']['values'][0] =
    {'validTime': '2024-06-20T04:00:00+00:00/PT13H', 'value': None}

  data['properties']['temperature']['values'][0] =
    {'validTime': '2024-06-20T04:00:00+00:00/PT2H', 'value': 18.88888888888889}

  The 'validTime' ends with, say, '13H' or '2H' which indicates the duration
  of the valid time window in hours.

  Then return the firehose, after doing some formatting / sprucing.
"""

import json
import sys
import os
import math
import urllib.request
from urllib.error import HTTPError, URLError
from datetime import datetime, timedelta


def getNWSRawHourlyURL(lat, lon):
  baseURL = 'https://api.weather.gov/points/'
  pointsURL = baseURL + str(lat) + ',' + str(lon)
  # print(pointsURL)

  try:
    pointsFP = urllib.request.urlopen(pointsURL)
  except HTTPError:
    print ("HTTP error opening " + pointsURL)
    return None
  except URLError:
    print ("URL error opening " + pointsURL)
    return None
  except TimeoutError:
    print ("Timeout error opening " + pointsURL)
    return None

  # Read in the URL contents as JSON data.
  try:
    pointsInfo = json.load(pointsFP)
  except ValueError:
    print ("Can't load 'points' info JSON data from " + pointsURL)
    return None
  return pointsInfo["properties"]["forecastGridData"]


def getNWSRawHourlyData(lat, lon):
  hourlyForecastURL = getNWSRawHourlyURL(lat, lon)
  if hourlyForecastURL is None:
    return None

  rawDataFP = urllib.request.urlopen(hourlyForecastURL)

  # Read in the URL contents as JSON data.
  try:
    rawData = json.load(rawDataFP)
  except ValueError:
    print ("Can't load raw forecast JSON data from " + hourlyForecastURL)
    return None
  return rawData


def getDateTimeFromNWSValidTime(vt):
  # vt will be something like '2024-06-20T17:00:00+00:00/PT2H'
  # Don't care about the stuff after the "+" symbol, just the date/time.
  vts = vt.split("+")
  dt = datetime.strptime(vts[0], '%Y-%m-%dT%H:%M:%S')
  return dt


def getNWSHourlyData(rawData):
  nwsHeatIndexValues = []
  for hi in rawData['properties']['heatIndex']['values']:
    if hi['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(hi['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["heatIndexC"] = hi['value']
    value["heatIndexF"] = (hi['value'] * 1.8) + 32
    nwsHeatIndexValues.append(value)

  nwsMinTemperatureValues = []
  for mint in rawData['properties']['minTemperature']['values']:
    if mint['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(mint['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["mintC"] = mint['value']
    value["mintF"] = (mint['value'] * 1.8) + 32
    nwsMinTemperatureValues.append(value)

  nwsMaxTemperatureValues = []
  for maxt in rawData['properties']['maxTemperature']['values']:
    if maxt['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(maxt['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["maxtC"] = maxt['value']
    value["maxtF"] = (maxt['value'] * 1.8) + 32
    nwsMaxTemperatureValues.append(value)

  nwsApparentTemperatureValues = []
  for appt in rawData['properties']['apparentTemperature']['values']:
    if appt['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(appt['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["apptC"] = appt['value']
    value["apptF"] = (appt['value'] * 1.8) + 32
    nwsApparentTemperatureValues.append(value)

  nwsWetBulbGlobeTemperatureValues = []
  for wbgt in rawData['properties']['wetBulbGlobeTemperature']['values']:
    if wbgt['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(wbgt['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["wgbtC"] = wbgt['value']
    value["wgbtF"] = (wbgt['value'] * 1.8) + 32
    nwsWetBulbGlobeTemperatureValues.append(value)

  nwsProbabilityOfPrecipitation = []
  for pop in rawData['properties']['probabilityOfPrecipitation']['values']:
    if pop['value'] is None:
      continue
    dt = getDateTimeFromNWSValidTime(pop['validTime'])
    value = {"year":dt.year, "month":dt.month, "day":dt.day, "hour":dt.hour}
    value["pop"] = pop['value']
    nwsProbabilityOfPrecipitation.append(value)

  nwsValues = {"heatIndex": nwsHeatIndexValues, \
    "minimumTemperature": nwsMinTemperatureValues, \
    "maximumTemperature": nwsMaxTemperatureValues, \
    "apparentTemperature": nwsApparentTemperatureValues, \
    "wetBulbGlobeTemperature": nwsWetBulbGlobeTemperatureValues, \
    "probabilityOfPrecipitation": nwsProbabilityOfPrecipitation}

  return nwsValues


# Test at your lat,lon:
# lat = <YOUR-LATITUDE>
# lon = <YOUR-LONGITUDE>
# rawData = getNWSRawHourlyData(lat, lon)
# print(rawData)
# data = getNWSHourlyData(rawData)
# print(data)





Version 2024-October-09.