Why?

I enjoy weather and living in the Houston area, we deal with hurricanes. Of course while the results can be devastating, I get a sort of sick excitedness whenever a new storm pops up. Just to watch them develop or prepare my home and area in case they’re headed our way. I saw someone’s twitter feed with a very short GIF someone had put together of the National Hurricane Center (NHC) 5-day forecasts and liked it enough that I wanted to be able to quickly and easily generate my own.

What

The web page I watch is the Atlantic 5-Day Graphical Tropical Weather Outlook. It shows current tropical cyclones and disturbances. In the case of disturbances, they’re represented by an X and given a forecast of where the NHC believes that disturbance is headed, along with percentage chances of that disturbance becoming a tropical cyclone over the next 2 to 5 days. From that page I found a link to an archive of all the previous 5 day forecasts.

The Software

TL;DR - The code repository is located here if you just want to get going and use the script.

By day I develop software in C++ and Python, so I chose Python for this relatively quick task. The high level order of steps was to:

  1. Grab the list of available 5 day forecasts
  2. Cull that list down to the user defined list of dates
  3. Make sure the latest forecast is included if it makes sense to
  4. Form the URLs for each image in the list
  5. Download all the images
  6. Create the GIF

Here’s the full script as of the time of this writing, of course to get the latest see the repo link above.

#!/usr/bin/env python3
#
# Copyright 2021 Chuck Claunch
#
# A script to grab the National Hurricane Center's (NHC) forecast images and turn them into an animated GIF
#
# This script scrapes the NHC archive site for avaialble 5-day forecasts within the user given or default
# time period, downloads the images to a local folder, then creates an animated GIF out of them based on
# user given or default paramaters.  Pass `--help` to the script to see its usage.
#
# License is hereby given to do whatever the heck you want with this code, but it'd be nice if you left
# this comment block in or at least linked back to this repository for credit.
#
# If you really enjoyed it, grab me a coffee: https://www.buymeacoffee.com/chux
# If you want to see my other projects: https://chux.gitlab.io/

import argparse
import datefinder
import datetime
import os.path
import urllib.request
from PIL import Image


def main(date_start, date_end, basin, duration, end_frames):
    """Main function to grab the images and create the GIF."""
    # Because NHC for whatever reason uses epac and pac in the same URLs.
    basin_alt = basin
    if basin == 'epac':
        basin_alt = 'pac'

    page = urllib.request.urlopen(
        'https://www.nhc.noaa.gov/archive/xgtwo/gtwo_archive_list.php?basin={basin}'.format(basin=basin))
    contents = page.read().decode(page.headers.get_content_charset())

    # All of the archive dates are between these tags.
    start = contents.find('<!-- START OF CONTENT -->')
    end = contents.find('<!-- END OF CONTENT -->')
    datestring = contents[start:end]
    matches = datefinder.find_dates(datestring)

    # Only grab the dates we're looking for.
    dates = []
    for match in matches:
        if date_start <= match <= date_end:
            dates.append(match)

    # If these were out of order the GIF would look kinda dumb.
    dates.sort()

    # If the user provided an end date that was more than a day ago we don't
    # want to tack on the 'latest' image from NHC.
    add_latest = False
    if (datetime.datetime.now() - dates[-1]).total_seconds() <= 60 * 60 * 24:
        add_latest = True

    # Get the dates as a string formatted the way the NHC URL wants them.
    dates_stripped = [d.strftime('%Y%m%d%H%M') for d in dates]

    # Add the latest NHC forecast if needed.
    if add_latest:
        dates_stripped.append('latest')

    # Duplicate the final frame to make the gif "pause" on the final image
    # before restarting.
    for _ in range(end_frames):
        dates_stripped.append(dates_stripped[-1])

    # Keep the downloaded images in a temporary directory.
    tmp_dir = 'tmp_{basin}'.format(basin=basin)
    if not os.path.exists(tmp_dir):
        os.makedirs(tmp_dir)

    # Iterate the list to generate the URLs, download the files if needed, and
    # create the final file list.
    files = []
    for report_date in dates_stripped:
        url = 'https://www.nhc.noaa.gov/archive/xgtwo/{basin}/{report_date}/two_{basin_alt}_5d0.png'.format(
            basin=basin, basin_alt=basin_alt, report_date=report_date)
        filename = '{tmp_dir}/{report_date}.png'.format(
            tmp_dir=tmp_dir, report_date=report_date)
        # If we already have the file, no need to download it again.  Always grab the latest one though.
        if not os.path.isfile(filename) or 'latest' in filename:
            try:
                urllib.request.urlretrieve(url, filename)
            except urllib.error.HTTPError as ex:
                # Let the user know if one failed (some of them just aren't there, thanks NHC).
                print(
                    'Failed to download {file}: {error}'.format(file=url, error=ex))
                continue
        files.append(filename)

    # Output by basin given, perhaps later this can be user input.
    output_file = 'hurricane_5day_{basin}.gif'.format(basin=basin)

    # Use the Pillow library to generate the GIF from the files.  Optimize here
    # doesn't seem to do much.
    img, *imgs = [Image.open(f) for f in files]
    img.save(fp=output_file, format='GIF', append_images=imgs,
             save_all=True, duration=duration, loop=0, optimize=True)


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-s', '--start-date', type=lambda s: datetime.datetime.strptime(
        s, '%Y-%m-%d'), help='If not specified, the current calendar year will be used.')
    parser.add_argument('-e', '--end-date', type=lambda s: datetime.datetime.strptime(s, '%Y-%m-%d'),
                        default=datetime.datetime.now(), help='If not specified, the current date and time will be used.  If --start-date is not specified, this is ignored.')
    parser.add_argument('-b', '--basin', default='atl',
                        choices=('atl', 'epac', 'cpac'), help='Basin to choose. atl=Atlantic, epac=Eastern Pacific, cpac=Central pacific (default: atl)')
    parser.add_argument('-d', '--duration', type=int, default=100,
                        help='Duration of each frame in milliseconds.')
    parser.add_argument('-f', '--frames-to-add', type=int, default=30,
                        help='Duplicates the final frame N times for a pause effect at the end (default: 30)')
    args = parser.parse_args()

    # If the user doesn't provide a start date, just use this entire year up to now.
    if not args.start_date:
        args.start_date = datetime.datetime(
            year=datetime.datetime.now().year, month=1, day=1)
        args.end_date = datetime.datetime.now()

    main(date_start=args.start_date, date_end=args.end_date,
         basin=args.basin, duration=args.duration, end_frames=args.frames_to_add)

The Result

Here’s a sample of using the script to follow 2021’s hurricane Ida from forecast to formation and track:

 ./hurricane_gif.py -s 2021-08-22 -e 2021-09-04 -d 50 -f 0

Full usage of the script (as of this writing):

usage: hurricane_gif.py [-h] [-s START_DATE] [-e END_DATE] [-b {atl,epac,cpac}] [-d DURATION] [-f FRAMES_TO_ADD]

optional arguments:
  -h, --help            show this help message and exit
  -s START_DATE, --start-date START_DATE
                        If not specified, the current calendar year will be used.
  -e END_DATE, --end-date END_DATE
                        If not specified, the current date and time will be used. If --start-date is not specified,
                        this is ignored.
  -b {atl,epac,cpac}, --basin {atl,epac,cpac}
                        Basin to choose. atl=Atlantic, epac=Eastern Pacific, cpac=Central pacific (default: atl)
  -d DURATION, --duration DURATION
                        Duration of each frame in milliseconds.
  -f FRAMES_TO_ADD, --frames-to-add FRAMES_TO_ADD
                        Duplicates the final frame N times for a pause effect at the end (default: 30)

The script works in both Linux and Windows (and likely Mac too but has not been tested there). Requirements and installation instructions are located on the readme on the repository.

Hope you enjoy this and find it useful! If you did want to buy me a coffee just click the little coffee icon in the lower right of this page.