Is the sargassum "peak month" real? Testing a Caribbean seaweed calendar with pandas
Most sargassum calendars give each Caribbean island one number per month: the average signal for June, the average for July, and so on. That's useful for comparing islands. But an average from nine years can hide a lot. In this post we test that calendar against the single years behind it, using pandas and two open CSV files.
Short version: the "peak month" an average gives you was the actual worst month in only 59 of 162 full island-years. Allow one month either side and it was right 98 times.
The data
The two files behind Gladori's Caribbean sargassum season by island page are published under CC BY 4.0. Both come from the University of South Florida floating algae index (AFAI, 7-day fields), served by NOAA AOML and sampled about once a week since January 2018.
-
sargassum-signal-by-year.csvhas one row per island, year and month (slug, year, month, signal_pct). -
sargassum-signal-by-month.csvaverages each calendar month across every year on file (signal_pct). It also haseast_pctandwest_pctand the worst year for that month (peak_year,peak_pct).
The signal is the share of clear sea pixels in a box about 70 miles (110 km) wide around each destination that reads above zero on the algae index. It's a way to compare islands and months. It doesn't measure tonnage, and it can't tell you what one beach looks like on one day.
Loading it
Each file starts with three # comment lines that hold the license and source, so comment="#" skips them. The server rejected Python's default user agent with a 403 when we tried, so we send a plain one.
import pandas as pd BASE = "https://gladori.com/data/files/" HEADERS = {"User-Agent": "sargassum-notebook/1.0"} yearly = pd.read_csv(BASE + "sargassum-signal-by-year.csv", comment="#", storage_options=HEADERS) monthly = pd.read_csv(BASE + "sargassum-signal-by-month.csv", comment="#", storage_options=HEADERS) print(yearly["slug"].nunique(), "destinations,", len(yearly), "island-month rows") print(yearly.groupby("slug").size().value_counts()) print(monthly.loc[monthly["west_pct"].isna(), "slug"].unique()) With the files as of early November 2026, that prints 21 destinations and 2,214 rows. Twenty destinations have 106 monthly rows each, January 2018 through October 2026. Tulum has 94, because some months have no reading. Tulum is also the only destination with no west_pct. That's worth knowing before you build anything on the east/west split.
Sanity check: rebuild the averages
Before trusting the monthly file, check that it really is the mean of the yearly rows:
rebuilt = yearly.groupby(["slug", "month"])["signal_pct"].mean() check = monthly.set_index(["slug", "month"])["signal_pct"] print((check - rebuilt).abs().max().round(4)) # 0.0007 The largest gap is 0.0007 percentage points, which is just rounding. So the monthly file is a plain mean. And a mean over five to nine values is easy for one big year to pull up.
Mean vs median for one month
Here is June for each destination: the mean, the median, and the range across years.
def spread(month): rows = yearly[yearly["month"] == month] out = rows.groupby("slug")["signal_pct"].agg(["mean", "median", "min", "max", "count"]) out["low_year"] = rows.loc[rows.groupby("slug")["signal_pct"].idxmin()].set_index("slug")["year"] out["high_year"] = rows.loc[rows.groupby("slug")["signal_pct"].idxmax()].set_index("slug")["year"] return out.sort_values("mean", ascending=False).round(1) spread(6) The busiest rows (signal in %):
| Destination | Mean | Median | Lowest year | Highest year | Years |
|---|---|---|---|---|---|
| Punta Cana | 29.1 | 27.5 | 7.5 (2024) | 50.9 (2018) | 9 |
| Tulum | 18.2 | 19.7 | 7.2 (2023) | 26.7 (2019) | 5 |
| Antigua | 11.4 | 8.6 | 4.6 (2019) | 23.0 (2018) | 9 |
| Jamaica (Montego Bay) | 11.0 | 8.2 | 1.1 (2024) | 37.8 (2022) | 9 |
| St. Lucia | 9.7 | 7.2 | 3.3 (2023) | 21.0 (2026) | 9 |
| St. Martin | 8.2 | 6.0 | 1.8 (2020) | 26.4 (2026) | 9 |
| Anguilla | 7.4 | 4.4 | 1.9 (2020) | 24.6 (2026) | 9 |
| Barbados | 7.0 | 4.4 | 1.5 (2020) | 20.2 (2018) | 9 |
Three things stand out:
- Means sit above medians for most of the busy islands. In Jamaica, one June (2022, at 37.8%) lifts the mean to 11.0% while the median is 8.2%. Anguilla's mean is 7.4% against a median of 4.4%.
- Ranges are wide. Punta Cana's June ran from 7.5% in 2024 to 50.9% in 2018. That's still high every year, but the typical June there isn't a fixed amount.
- 2026 set several June highs. It was the top June on file for St. Lucia, St. Martin, Anguilla, St. Barts, Mustique, Virgin Gorda, Grenada and Turks and Caicos. A nine-year mean that includes a record year will read higher than most of the years in it.
Tulum has only five June readings, so treat its row with care.
Does the average peak month actually win?
Now the main test. For each island, take the month with the highest average. Then, for every year with all 12 months on file, find the month that was really worst that year and compare.
peak = monthly.loc[monthly.groupby("slug")["signal_pct"].idxmax()].set_index("slug")["month"] full_years = yearly.groupby(["slug", "year"]).filter(lambda g: len(g) == 12) worst = full_years.loc[full_years.groupby(["slug", "year"])["signal_pct"].idxmax()].copy() worst["avg_peak"] = worst["slug"].map(peak) gap = (worst["month"] - worst["avg_peak"]).abs() worst["exact"] = gap == 0 worst["near"] = gap.isin([0, 1, 11]) # 11 lets December sit next to January score = worst.groupby("slug").agg(years=("year", "size"), exact=("exact", "sum"), near=("near", "sum")) That gives 162 full island-years: eight each for 20 destinations (2018 to 2025), plus two for Tulum. Across all of them:
- the average peak month was the real worst month 59 times (about 36%)
- it was within one month 98 times (about 60%)
Some islands are much more regular than others:
| Destination | Peak month by average | Exact hits | Within one month |
|---|---|---|---|
| Anguilla | July | 6 of 8 | 7 of 8 |
| St. Martin | July | 5 of 8 | 6 of 8 |
| Antigua | June | 4 of 8 | 6 of 8 |
| St. Barts | July | 4 of 8 | 6 of 8 |
| Virgin Gorda | July | 2 of 8 | 7 of 8 |
| Punta Cana | June | 2 of 8 | 5 of 8 |
| St. Lucia | May | 2 of 8 | 3 of 8 |
| Mustique | May | 1 of 8 | 3 of 8 |
The cluster in the northeast (Anguilla, St. Martin, St. Barts) tends to peak in July year after year. In Punta Cana the worst month has moved around between May and September. That's why "peak month" there is better read as "peak season".
Low-signal islands like Aruba, Curacao and the Bahamas peak by average in January (Exuma in November). Their numbers are so small that the "worst month" there is mostly noise.
A wider window: months at half the peak or more
If a single month is fragile, a window is more honest. This marks every month whose average is at least half of that island's highest month:
monthly["share_of_peak"] = monthly["signal_pct"] / monthly.groupby("slug")["signal_pct"].transform("max") window = monthly[monthly["share_of_peak"] >= 0.5].groupby("slug")["month"].apply(list) Some results:
- Punta Cana, Virgin Gorda, St. John: May to September
- St. Lucia, Dorado, Tulum: April to September
- Barbados, Antigua: April to August
- St. Barts: May to August
- Anguilla, St. Martin: May to July
- Grand Cayman: June to August
- Turks and Caicos: July to September
Some windows have gaps. Jamaica's is June, July and September. Grenada's is April, May and July. So the averaged season isn't always a smooth curve, and you shouldn't force it into one.
The 50% cutoff is our choice and has nothing to do with the dataset. Move it and the windows move too.
Limits worth repeating
These come from the source page's method notes, and they apply to everything above:
- Reefs and shallow water read above zero all year. Pixels that read above zero in more than a quarter of their samples are dropped. A stricter cutoff would lower every figure.
- Clouds hide pixels, and the satellites miss the water right at the shore.
- The signal compares islands and months. It isn't a measure of weight, and it isn't a beach forecast.
- The underlying satellite data isn't intended for legal use.
Takeaways
- A monthly average is a fair way to rank islands, but a weak way to pick one week. In 2018 to 2025, the average's peak month matched the real worst month in only about a third of full island-years.
- Report the median and range next to the mean. With nine years and a record 2026 in the mix, the gap matters.
- Read the season as a window, not as one month.
The script runs as-is against the live CSVs, so the tables update when the files do.
Gladori data team. Data: Gladori (CC BY 4.0), from USF AFAI via NOAA AOML.