been retained by an insurance coverage firm to assist refine dwelling insurance coverage premiums throughout the southeastern United States. Their query is easy however excessive stakes: which counties are hit most frequently by hurricanes? They usually don’t simply imply landfall, they wish to account for storms that hold driving inland, delivering damaging rain and spawning tornadoes.
To deal with this, you’ll want two key elements:
- A dependable storm observe database
- A county boundary shapefile
With these, the workflow turns into clear: determine and depend each hurricane observe that intersects a county boundary, then visualize the ends in each map and checklist kind for optimum perception.
Python is a perfect match for this job, because of its wealthy ecosystem of geospatial and scientific libraries:
- Tropycal for pulling open-source authorities hurricane information
- GeoPandas for loading and manipulating geospatial recordsdata
- Plotly Specific for constructing interactive, explorable maps
Earlier than diving into the code, let’s look at the outcomes. We’ll deal with the interval 1975 to 2024, when world warming, believed to affect Atlantic hurricanes, grew to become firmly established.

During the last 49 years, hurricanes have struck 640 counties within the southeastern US. Coastal counties bear the brunt of wind and storm surge, whereas inland areas undergo from torrential rain and the occasional hurricane-spawned twister. It’s a posh, far-reaching hazard, and with the fitting instruments, you may map it county by county.
The next map, constructed utilizing the Tropycal library, information the tracks of all of the hurricanes that made landfall within the US from 1975 by way of 2024.

Whereas fascinating, this map isn’t a lot use to an insurance coverage adjuster. We have to quantify it by including county-level decision and counting the variety of distinctive tracks that cross into every county. Right here’s how that appears:

Now we now have a greater thought of which counties act as “hurricane magnets.” Throughout the Southeast, hurricane “hit” counts vary from zero to 12 per county — however the storms are removed from evenly distributed. Hotspots cluster alongside the Louisiana coast, in central Florida, and alongside the shorelines of the Carolinas. The East Coast actually takes it on the chin, with Brunswick County, North Carolina, holding the unwelcome document for probably the most hurricane strikes.
A look on the observe map makes the sample clear. Florida, Georgia, South Carolina, and North Carolina sit within the crosshairs of two storm highways — one from the Atlantic and one other from the Gulf of Mexico. The prevailing westerlies, which start simply north of the Gulf Coast, typically bend northward-tracking storms towards the Atlantic seaboard. Fortuitously for Georgia and the Carolinas, many of those techniques lose energy over land, slipping under hurricane power earlier than sweeping by way of.
For insurers, these visualizations aren’t simply climate curiosities; they’re decision-making instruments. And layering in historic loss information can present a extra full image of the true monetary value of residing by the water’s edge.
The Choropleth Code
The next code, written in JupyterLab, creates a choropleth map of hurricane observe counts per county. It makes use of geospatial information from the Plotly graphing library and pulls open-source climate information from the Nationwide Oceanic and Atmospheric Administration (NOAA) utilizing the Tropycal library.
The code makes use of the next packages:
python 3.10.18
numpy 2.2.5
geopandas 1.0.1
plotly 6.0.1 (plotly_express 0.4.1)
tropical 1.4
shapely 2.0.6
Importing Libraries
Begin by importing the next libraries.
import json
import numpy as np
import geopandas as gpd
import plotly.specific as px
from tropycal import tracks
from shapely.geometry import LineString
Configuring Constants
Now, we arrange a number of constants. The primary is a set of the state “FIPS” codes. Brief for Federal Info Processing Sequence, these “zip codes for states” are generally utilized in geospatial recordsdata. On this case, they characterize the southeastern states of Alabama, Florida, Georgia, Louisiana, Mississippi, North Carolina, South Carolina, and Texas. Later, we’ll use these codes to filter a single file of the complete USA.
# CONFIGURE CONSTANTS
# State: AL, FL, GA, LA, MS, NC, SC, TX:
SE_STATE_FIPS = {'01', '12', '13', '22', '28', '37', '45', '48'}
YEAR_RANGE = (1975, 2024)
INTENSITY_THRESH = {'v_min': 64} # Hurricanes (>= 64 kt)
COUNTY_GEOJSON_URL = (
'https://uncooked.githubusercontent.com/plotly/datasets/grasp/geojson-counties-fips.json'
)
Subsequent, we outline a yr vary (1975-2024) as a tuple. Then, we assign an depth threshold fixed for wind velocity. Tropycal will filter storms primarily based on wind speeds, and people with speeds of 64 knots or larger are categorised as hurricanes.
Lastly, we offer the URL handle for the Plotly library’s counties geospatial shapefile. Later, we’ll use GeoPandas to load this as a GeoDataFrame, which is actually a pandas DataFrame with a Geometry column for geospatial mapping info.
NOTE: Hurricanes shortly turn into tropical storms and depressions after making landfall. These are nonetheless harmful, nonetheless, so we’ll proceed to trace them.
Defining Helper Capabilities
To streamline the hurricane mapping workflow, we’ll outline three light-weight helper features. These will assist hold the code modular, readable, and adaptable, particularly when working with real-world geospatial information which will range in construction or scale.
# Outline Helper Capabilities:
def get_hover_name_column(df: gpd.GeoDataFrame) -> str:
# Want proper-case county identify if accessible:
if 'NAME' in df.columns:
return 'NAME'
if 'identify' in df.columns:
return 'identify'
# Fallback to id if no identify column exists:
return 'id'
def storm_to_linestring(storm_obj) -> LineString | None:
df = storm_obj.to_dataframe()
if len(df) < 2:
return None
coords = [(lon, lat) for lon, lat in zip(df['lon'], df['lat'])
if not (np.isnan(lon) or np.isnan(lat))]
return LineString(coords) if len(coords) > 1 else None
def make_tickvals(vmax: int) -> checklist[int]:
if vmax <= 10:
step = 2
elif vmax <= 20:
step = 4
elif vmax <= 50:
step = 10
else:
step = 20
return checklist(vary(0, int(vmax) + 1, step)) or [0]
Plotly Specific creates interactive visuals. We’ll be capable to hover the cursor over counties within the choropleth map and launch a pop-up window of the county identify and the variety of hurricanes which have handed by way of. The get_hover_name_column(df)
operate selects probably the most readable column identify for map hover labels. It checks for 'NAME'
or 'identify'
within the GeoDataFrame and defaults to 'id'
if neither is discovered. This ensures constant labeling throughout datasets.
The storm_to_linestring(storm_obj)
operate converts a storm’s observe information right into a LineString
geometry by extracting legitimate longitude–latitude pairs. If the storm has fewer than two legitimate factors, it returns ‘None’. That is important for spatial joins and visualizing storm paths.
Lastly, the make_tickvals(vmax)
operate generates a clear set of tick marks for the choropleth colorbar primarily based on the utmost hurricane depend. It dynamically adjusts the step dimension to maintain the legend readable, whether or not the vary is small or giant.
Put together the County Map
The following cell masses the geospatial information and filters out the southeastern states, utilizing our ready set of FIPS codes. Within the course of, it creates a GeoDataFrame and provides a column for the Plotly Specific hover information.
# Load and filter county boundary information:
counties_gdf = gpd.read_file(COUNTY_GEOJSON_URL)
# Guarantee FIPS id is string with main zeros:
counties_gdf['id'] = counties_gdf['id'].astype(str).str.zfill(5)
# Derive state code from id's first two digits:
counties_gdf['STATE_FIPS'] = counties_gdf['id'].str[:2]
se_counties_gdf = (counties_gdf[counties_gdf['STATE_FIPS'].
isin(SE_STATE_FIPS)].copy())
hover_col = get_hover_name_column(se_counties_gdf)
print(f"Loading county information...")
print(f"Loaded {len(se_counties_gdf)} southeastern counties")
To start, we load the county-level GeoJSON file utilizing GeoPandas and put together it for evaluation. Every county is recognized by a FIPS code, which we format as a 5-digit string to make sure consistency (the primary two digits characterize the state code). We then extract the state portion of every FIPS code and filter the dataset to incorporate solely counties in our eight southeastern states. Lastly, we choose a column for labeling counties within the hover textual content and ensure the variety of counties which were loaded.
Fetching and Processing Hurricane Knowledge
Now it’s time to make use of Tropycal to fetch and course of the hurricane information from the Nationwide Hurricane Heart. That is the place we programmatically overlay the counties with the hurricane tracks and depend the distinctive occurrences of tracks in every county.
# Get and course of hurricane information utilizing Tropycal library:
attempt:
atlantic = tracks.TrackDataset(basin='north_atlantic',
supply='hurdat',
include_btk=True)
storms_ids = atlantic.filter_storms(thresh=INTENSITY_THRESH,
year_range=YEAR_RANGE)
print(f"Discovered {len(storms_ids)} hurricanes from "
f"{YEAR_RANGE[0]}–{YEAR_RANGE[1]}")
storm_names = []
storm_tracks = []
for i, sid in enumerate(storms_ids, begin=1):
if i % 50 == 0 or i == 1 or i == len(storms_ids):
print(f"Processing storm {i}/{len(storms_ids)}")
attempt:
storm = atlantic.get_storm(sid)
geom = storm_to_linestring(storm)
if geom just isn't None:
storm_tracks.append(geom)
storm_names.append(storm.identify)
besides Exception as e:
print(f" Skipped {sid}: {e}")
print(f"Efficiently processed {len(storm_tracks)} storm tracks")
hurricane_tracks_gdf = gpd.GeoDataFrame({'identify': storm_names},
geometry=storm_tracks,
crs="EPSG:4326")
# Pre-filter tracks to the bounding field of the SE counties for velocity:
xmin, ymin, xmax, ymax = se_counties_gdf.total_bounds
hurricane_tracks_gdf = hurricane_tracks_gdf.cx[xmin:xmax, ymin:ymax]
# Verify that county information and hurricane tracks are similar CRS:
assert se_counties_gdf.crs == hurricane_tracks_gdf.crs,
f"CRS mismatch: {se_counties_gdf.crs} vs {hurricane_tracks_gdf.crs}"
# Spatial be part of to seek out counties intersecting hurricane tracks:
print("Performing spatial be part of...")
joined = gpd.sjoin(se_counties_gdf[['id', hover_col, 'geometry']],
hurricane_tracks_gdf[['name', 'geometry']],
how="interior",
predicate="intersects")
# Depend distinctive hurricanes per county:
unique_pairs = joined[['id', 'name']].drop_duplicates()
hurricane_counts = (unique_pairs.groupby('id', as_index=False).dimension().
rename(columns={'dimension': 'hurricane_count'}))
# Merge counts again
se_counties_gdf = se_counties_gdf.merge(hurricane_counts,
on='id',
how='left')
se_counties_gdf['hurricane_count'] = (se_counties_gdf['hurricane_count'].
fillna(0).astype(int))
print(f"Hurricane counts: Max: {se_counties_gdf['hurricane_count'].max()} | "
f"Nonzero counties: {(se_counties_gdf['hurricane_count'] > 0).sum()}")
besides Exception as e:
print(f"Error loading hurricane information: {e}")
print("Creating pattern information for demonstration...")
np.random.seed(42)
se_counties_gdf['hurricane_count'] = np.random.poisson(2,
len(se_counties_gdf))
Right here’s a breakdown of the key steps:
- Load Dataset: Initializes the
TrackDataset
utilizing HURDAT information, together with greatest observe (btk
) factors. - Filter Storms: Selects hurricanes that meet a specified depth threshold and fall inside a given yr vary.
- Extract Tracks: Iterates by way of every storm ID, converts its path to a
LineString
geometry, and shops each the observe and storm identify. Progress is printed each 50 storms. - Create GeoDataFrame: Combines storm names and geometries right into a GeoDataFrame with WGS84 coordinates.
- Spatial Filtering: Clips hurricane tracks to the bounding field of southeastern counties to enhance efficiency.
- Assert CRS: Checks that the county and hurricane information use the identical coordinate reference system (in case you wish to use totally different geospatial and/or hurricane observe recordsdata).
- Spatial Be part of: Identifies which counties intersect with hurricane tracks utilizing a spatial be part of.
Performing the spatial be part of may be tough. For instance, if a observe doubles again and re-enters a county, you don’t wish to depend it twice.

To deal with this, the code first identifies distinctive identify pairs after which drops duplicate rows from the GeoDataFrame earlier than performing the depend.
- Depend Hurricanes per County:
- Drops duplicate storm–county pairs.
- Teams by county ID to depend distinctive hurricanes.
- Merges outcomes again into the county GeoDataFrame.
- Fills lacking values with zero and converts to integer.
- Fallback Dealing with: If hurricane information fails to load, artificial hurricane counts are generated utilizing a Poisson distribution for demonstration functions. That is for studying the method, solely!
Errors loading the hurricane information are widespread, so keep watch over the printout. If the information fails to load, hold rerunning the cell till it does.
A profitable run will yield the next affirmation:

Constructing the Choropleth Map
The following cell generates a custom-made choropleth map of hurricane counts per county within the Southeastern US utilizing Plotly Specific.
# Construct the choropleth map:
print("Creating choropleth map...")
se_geojson = json.masses(se_counties_gdf.to_json())
max_count = int(se_counties_gdf['hurricane_count'].max())
tickvals = make_tickvals(max_count)
fig = px.choropleth(se_counties_gdf,
geojson=json.masses(se_counties_gdf.to_json()),
areas='id',
featureidkey='properties.id',
colour='hurricane_count',
color_continuous_scale='Reds',
range_color=[0, max_count],
title=(f"Southeastern US: Hurricane Counts Per County "
f"({YEAR_RANGE[0]}–{YEAR_RANGE[1]})"),
hover_name=hover_col,
hover_data={'hurricane_count': True, 'id': False})
# Alter the map structure and clear the Plotly hover information:
fig.update_geos(fitbounds="areas", seen=False)
fig.update_traces(
hovertemplate="%{hovertext}
Hurricanes: %{z}"
)
fig.update_layout(
width=1400,
top=1000,
title=dict(
textual content=(f"Southeastern US: Hurricane Counts Per County "
f"({YEAR_RANGE[0]}–{YEAR_RANGE[1]})"),
x=0.5,
xanchor='heart',
y=0.85,
yanchor='high',
font=dict(dimension=24),
pad=dict(t=0, b=10)
),
coloraxis_colorbar=dict(
x=0.96,
y=0.5,
len=0.4,
thickness=16,
title='Hurricane Depend',
outlinewidth=1,
tickvals=tickvals,
tickfont=dict(dimension=16)
)
)
fig.add_annotation(
textual content="Knowledge: HURDAT2 by way of Tropycal | Metric: counties intersecting hurricane "
f"tracks ({YEAR_RANGE[0]}–{YEAR_RANGE[1]})",
x=0.521,
y=0.89,
showarrow=False,
font=dict(dimension=16),
xanchor='heart'
)
fig.present()
The important thing steps right here embody:
- GeoJSON Conversion: Converts the GeoDataFrame of counties to GeoJSON format for simple mapping with Plotly Specific.
- Coloration Scaling: Determines the utmost hurricane depend and calls the helper operate to create tick values for the colorbar.
- Map Rendering:
- Makes use of
px.choropleth
to visualisehurricane_count
per county.- The
areas='id'
argument tells Plotly which column within the GeoDataFrame incorporates the distinctive identifiers for every county (county-level FIPS codes). These values match every row of knowledge to the corresponding form within the GeoJSON file. - The
featureidkey='properties.id'
argument specifies the place to seek out the matching identifier contained in the GeoJSON construction. GeoJSON options have aproperties
dictionary containing an'id'
subject. This ensures that every county’s geometry is accurately paired with its hurricane depend. - Applies a pink colour scale, units the vary, and defines hover habits.
- The
- Makes use of
- Format & Styling:
- Facilities and types the title.
- Adjusts map bounds and hides geographic outlines.
- The
fig.update_geos(fitbounds="areas", seen=False)
line turns off the bottom map for a cleaner plot.
- The
- Refines hover tooltips for readability.
- Customizes the colorbar with tick marks and labels.
- Annotation: Provides a knowledge supply notice referencing HURDAT2 and the evaluation metric.
- Show: Reveals the ultimate interactive map with
fig.present()
.
The deciding think about utilizing Plotly Specific over static instruments like Matplotlib is the addition of the dynamic hover information. Since there’s no sensible method to label lots of of counties, the hover information allows you to question the map whereas protecting all that additional info out of sight till wanted.

The Monitor Map Code
Though pointless, viewing the precise hurricane tracks could be a pleasant contact, in addition to a method to test the choropleth outcomes. This map may be generated fully with the Tropycal library, as proven under.
# Plot tracks coloured by class:
title = 'SE USA Hurricanes (1975-2024)'
ax = atlantic.plot_storms(storms=storms_ids,
title=title,
area={'w':-97.68,'e':-70.3,'s':22,'n':ymax},
prop={'plot_names':False,
'dots':False,
'linecolor':'class',
'linewidth':1.0},
map_prop={'plot_gridlines':False})
# plt.savefig('counties_tracks.png', dpi=600, bbox_inches='tight')
Observe that the area
parameter refers back to the boundaries of the map. Whereas you need to use our earlier xmin
, xmax
, ymin
, and ymax
variables, I’ve adjusted them barely for a extra visually interesting map. Right here’s the consequence:

For extra on utilizing the Tropycal library, see my earlier article: Straightforward Hurricane Monitoring with Tropycal | by Lee Vaughan | TDS Archive | Medium.
The Hurricane Listing Code
No insurance coverage adjuster will wish to cursor by way of a map to extract information. As a result of GeoDataFrames are a type of pandas DataFrame, it’s simple to slice and cube the information and current it as tables. The next code types the counties by hurricane depend after which, for brevity, shows the highest 20 counties primarily based on their depend.
Right here’s the fast and straightforward method to generate this desk; I’ve added some additional code for the state abbreviations:
# Map FIPS to state abbreviation:
fips_to_abbrev = {'01': 'AL', '12': 'FL', '13': 'GA', '22': 'LA',
'28': 'MS', '37': 'NC', '45': 'SC', '48': 'TX'}
# Add state abbreviation column:
se_counties_gdf['state_abbrev'] = se_counties_gdf['STATE'].map(fips_to_abbrev)
# Kind and choose high 20 counties by hurricane depend
top20 = (se_counties_gdf.sort_values(by='hurricane_count',
ascending=False)
[['state_abbrev', 'NAME', 'hurricane_count']].head(20))
# Show consequence
print(top20.to_string(index=False))
And right here’s the consequence:

Whereas this works, it’s not very professional-looking. We are able to enhance it utilizing an HTML method:
# Print out the highest 20 counties primarily based on hurricane impacts:
# Map FIPS to state abbreviation:
fips_to_abbrev = {'01': 'AL', '12': 'FL', '13': 'GA', '22': 'LA',
'28': 'MS', '37': 'NC', '45': 'SC', '48': 'TX'}
gdf_sorted = se_counties_gdf.copy()
# Add new column for state abbreviation:
gdf_sorted['State Name'] = gdf_sorted['STATE'].map(fips_to_abbrev)
# Rename Present Columns:
# A number of columns without delay
gdf_sorted = gdf_sorted.rename(columns={'NAME': 'County Identify',
'hurricane_count': 'Hurricane Depend'})
# Kind by hurricane_count:
gdf_sorted = gdf_sorted.sort_values(by='Hurricane Depend', ascending=False)
# Create a lovely HTML show:
df_display = gdf_sorted[['State Name', 'County Name', 'Hurricane Count']].head(20)
df_display['Hurricane Count'] = df_display['Hurricane Count'].astype(int)
# Create styled HTML desk with out index:
styled_table = (
df_display
.model
.set_caption("Prime 20 Counties by Hurricane Impacts")
.set_table_styles([
# Hide the index
{'selector': 'th.row_heading',
'props': [('display', 'none')]},
{'selector': 'th.clean',
'props': [('display', 'none')]},
# Caption styling:
{'selector': 'caption',
'props': [('caption-side', 'top'),
('font-size', '16px'),
('font-weight', 'bold'),
('text-align', 'center'),
('color', '#333')]},
# Header styling:
{'selector': 'th.col_heading',
'props': [('background-color', '#004466'),
('color', 'white'),
('text-align', 'center'),
('padding', '6px')]},
# Cell styling:
{'selector': 'td',
'props': [('text-align', 'center'),
('padding', '6px')]}
])
# Add zebra striping:
.apply(lambda col: [
'background-color: #f2f2f2' if i % 2 == 0 else ''
for i in range(len(col))
], axis=0)
)
# Save styled HTML desk to disk:
styled_table.to_html("top20_hurricane_table.html")
styled_table
This cell transforms our uncooked geospatial information right into a clear, publication-ready abstract of hurricane publicity by county. It prepares and presents a ranked desk of the 20 counties most affected by hurricanes:
- State Abbreviation Mapping: It begins by mapping every county’s FIPS state code to its two-letter abbreviation (e.g.,
'48' → 'TX'
) and provides this as a brand new column. - Column Renaming: The county identify (
'NAME'
) and hurricane depend ('hurricane_count'
) columns are renamed to'County Identify'
and'Hurricane Depend'
for readability. - Sorting and Choice: The GeoDataFrame is sorted in descending order by hurricane depend, and the highest 20 rows are chosen.
- Styled Desk Creation: Utilizing pandas’ Styler, the code builds a visually formatted HTML desk:
- Provides a centered caption
- Hides the index column
- Applies customized header and cell styling
- Provides zebra striping for readability
- Export to HTML: The styled desk is saved as
top20_hurricane_table.html
, making it simple to embed in studies or share externally.
Right here’s the consequence:

This desk may be additional enhanced by together with interactive sorting or by embedding it instantly right into a dashboard.
Abstract
On this mission, we addressed a query on each actuary’s desk: Which counties get hit the toughest by hurricanes, yr after yr? Python’s wealthy ecosystem of third-party packages was key to creating this simple and efficient. Tropycal made accessing authorities hurricane information a breeze, Plotly supplied the county boundaries, and GeoPandas merged the 2 datasets and counted the variety of hurricanes per county. Lastly, Plotly Specific produced a dynamic, interactive map that made it simple to visualise and discover the county-level hurricane information.