之前调研文献发现很多同学喜欢使用 jma 的台风路径数据,于是有了这篇教程。
本教程演示如何读取日本气象厅的 JMA 最佳路径文件,并绘制 2025 年全部台风路径图。
教程目标:
JMA 最佳路径文件由多段台风记录组成:
66666 开头的行为台风头信息;66666,表示下一个台风开始。本教程采用的筛选规则是:
2501、2502);25 开头的记录,也就是 2025 年编号的台风;下面的说明整理自 JMA / RSMC Tokyo 官方页面 Format of RSMC Best Track Data:
https://www.jma.go.jp/jma/jma-eng/jma-center/rsmc-hp-pub-eg/Besttracks/e_format_bst.html
每个台风开始时都有一行头信息,典型结构如下:
66666 2501 022 0001 2501 0 6 WUTIP 20250912
主要字段含义:
字段 | 说明 |
|---|---|
66666 | 记录起始标识 |
BBBB | 国际编号(年份后两位 + 当年达到 TS 强度及以上的序号) |
CCC | 该台风后续数据行数量 |
DDDD | 热带气旋编号 |
EEEE | 国际编号(与前面的国际编号对应) |
F | 最后一条数据的状态标志:0 表示消散,1 表示移出 RSMC Tokyo 责任海区 |
G | 最后一条数据时刻与最终分析时刻的小时差 |
H...H | 台风名称(20 列定宽字符) |
IIIIIIII | 最近修订日期,格式为 yyyymmdd |
典型结构如下:
25061300 002 4 183 1084 980 055 90030 0030 90180 0180
本行字段含义为:
字段 | 说明 |
|---|---|
yymmddhh | 分析时次,JMA 官方格式采用 UTC |
002 | 数据行标识 |
C | 强度等级代码(grade code) |
DDD | 中心纬度,单位为 0.1° |
EEEE | 中心经度,单位为 0.1° |
FFFF | 中心气压,单位为 hPa |
GGG | 最大持续风速,单位为 kt |
H IIII JJJJ | 50 kt 风圈信息,分别表示最长半径所在方向、最长半径、最短半径 |
K LLLL MMMM | 30 kt 风圈信息,分别表示最长半径所在方向、最长半径、最短半径 |
末尾 | 还可能带有登陆/经过日本列岛的标记位 |
官方常见代码说明如下:
代码 | 含义 |
|---|---|
2 | Tropical Depression(热带低压,TD) |
3 | Tropical Storm(热带风暴,TS) |
4 | Severe Tropical Storm(强热带风暴,STS) |
5 | Typhoon(台风,TY) |
6 | Extra-tropical Cyclone(温带气旋) |
7 | Just entering the responsible area of RSMC Tokyo-Typhoon Center(刚进入责任海区) |
9 | Tropical Cyclone of TS intensity or higher(TS 强度及以上热带气旋) |
JMA 风圈方向代码用于描述"最长半径"所在象限或方向:
代码 | 含义 |
|---|---|
0 | 无风圈 / 半径为 0 |
1 | NE |
2 | E |
3 | SE |
4 | S |
5 | SW |
6 | W |
7 | NW |
8 | N |
9 | 同心圆(各方向相同) |
为了让 notebook 更聚焦于"路径可视化",本教程当前重点解析了以下字段:
timegrade_codelatitude / longitudepressure_hpamax_wind_kt风圈半径、方向代码以及登陆标志在官方格式中同样很重要,但为了保持代码简洁,这里先不纳入主解析流程。
注意:
time:按 yyMMddHH 解析;latitude / longitude:原始数据以 0.1 度 为单位,所以需要除以 10;max_wind_kt:若值为 000,这里视为缺失值;25 开头的台风。import re
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import cartopy.crs as ccrs
import cartopy.feature as cfeature
DATA_PATH = Path('bst_all.txt')
OUTPUT_FIG = Path('jma_2025_typhoon_tracks.png')
HEADER_PATTERN = re.compile(
r'^66666\s+'
r'(?P<international_number>\d{4})\s+'
r'(?P<num_lines>\d{3})\s+'
r'(?P<tc_id>\d{4})\s+'
r'(?P<jma_id>\d{4})\s+'
r'(?P<header_flag>\d)\s+'
r'(?P<time_interval>\d)\s*'
r'(?P<name>.*?)\s+'
r'(?P<revision_date>\d{8})\s*$'
)
GRADE_MAP = {
2: 'Tropical Depression',
3: 'Tropical Storm',
4: 'Severe Tropical Storm',
5: 'Typhoon',
6: 'Extratropical Cyclone',
7: 'Just entering the responsible area of RSMC Tokyo-Typhoon Center',
9: 'Tropical Cyclone of TS intensity or higher',
}
WIND_DIR_MAP = {
0: 'None / Radius=0',
1: 'NE quadrant',
2: 'E semicircle',
3: 'SE quadrant',
4: 'S semicircle',
5: 'SW quadrant',
6: 'W semicircle',
7: 'NW quadrant',
8: 'N semicircle',
9: 'Full circle (concentric)',
}
def parse_jma_best_track(file_path: Path, year_prefix: str = '25') -> pd.DataFrame:
rows = []
current_storm = None
for raw_line in file_path.read_text(encoding='utf-8', errors='ignore').splitlines():
if raw_line.startswith('66666'):
match = HEADER_PATTERN.match(raw_line)
current_storm = None
if match:
meta = match.groupdict()
if meta['international_number'].startswith(year_prefix):
current_storm = meta
continue
if current_storm isNoneornot raw_line.strip():
continue
parts = raw_line.split()
if len(parts) < 6:
continue
obs_time = pd.to_datetime(parts[0], format='%y%m%d%H')
grade_code = int(parts[2])
latitude = int(parts[3]) / 10.0
longitude = int(parts[4]) / 10.0
pressure_hpa = int(parts[5])
max_wind_kt = int(parts[6]) if len(parts) > 6else np.nan
# Wind radii parsing (tokens 7–10 present when len(parts) >= 11)
if len(parts) >= 11:
r50_dir = int(parts[7][0])
r50_long = int(parts[7][1:])
r50_short = int(parts[8])
r30_dir = int(parts[9][0])
r30_long = int(parts[9][1:])
r30_short = int(parts[10])
else:
r50_dir = r50_long = r50_short = np.nan
r30_dir = r30_long = r30_short = np.nan
rows.append(
{
'international_number': current_storm['international_number'],
'name': current_storm['name'].strip(),
'time': obs_time,
'grade_code': grade_code,
'grade_name': GRADE_MAP.get(grade_code, f'Unknown({grade_code})'),
'latitude': latitude,
'longitude': longitude,
'pressure_hpa': pressure_hpa,
'max_wind_kt': np.nan if max_wind_kt == 0else max_wind_kt,
'wind_radius_50kt_dir': r50_dir if r50_dir != 0else np.nan,
'wind_radius_50kt_long_nm': r50_long if r50_long != 0else np.nan,
'wind_radius_50kt_short_nm': r50_short if r50_short != 0else np.nan,
'wind_radius_30kt_dir': r30_dir if r30_dir != 0else np.nan,
'wind_radius_30kt_long_nm': r30_long if r30_long != 0else np.nan,
'wind_radius_30kt_short_nm': r30_short if r30_short != 0else np.nan,
}
)
df = pd.DataFrame(rows)
if df.empty:
raise ValueError('没有解析到 2025 年台风记录,请检查数据文件格式。')
return df.sort_values(['international_number', 'time']).reset_index(drop=True)
def summarize_typhoons(df: pd.DataFrame) -> pd.DataFrame:
summary = (
df.groupby(['international_number', 'name'], as_index=False)
.agg(
start_time=('time', 'min'),
end_time=('time', 'max'),
track_points=('time', 'size'),
min_pressure_hpa=('pressure_hpa', 'min'),
max_wind_kt=('max_wind_kt', 'max'),
max_latitude=('latitude', 'max'),
min_latitude=('latitude', 'min'),
max_longitude=('longitude', 'max'),
min_longitude=('longitude', 'min'),
)
.sort_values('international_number')
.reset_index(drop=True)
)
return summary
df_2025 = parse_jma_best_track(DATA_PATH, year_prefix='25')
summary_2025 = summarize_typhoons(df_2025)
print(f'共解析出台风 {df_2025["international_number"].nunique()} 个')
print(f'共解析出路径点 {len(df_2025)} 条')
输出:
共解析出台风 27 个
共解析出路径点 882 条
summary_2025.head():
international_number | name | start_time | end_time | track_points | min_pressure_hpa | max_wind_kt | max_latitude | min_latitude | max_longitude | min_longitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2501 | WUTIP | 2025-06-10 12:00:00 | 2025-06-15 18:00:00 | 22 | 980 | 55.0 | 28.1 | 15.0 | 120.7 | 108.2 |
1 | 2502 | SEPAT | 2025-06-21 06:00:00 | 2025-06-26 12:00:00 | 22 | 1004 | 35.0 | 38.6 | 20.9 | 147.0 | 139.5 |
2 | 2503 | MUN | 2025-07-01 00:00:00 | 2025-07-11 00:00:00 | 41 | 990 | 50.0 | 47.9 | 21.8 | 183.3 | 145.1 |
3 | 2504 | DANAS | 2025-07-03 00:00:00 | 2025-07-11 00:00:00 | 33 | 965 | 75.0 | 27.9 | 19.2 | 122.4 | 113.6 |
4 | 2505 | NARI | 2025-07-09 18:00:00 | 2025-07-17 00:00:00 | 35 | 990 | 45.0 | 48.5 | 19.2 | 183.7 | 140.0 |
df_2025.head():
international_number | name | time | grade_code | grade_name | latitude | longitude | pressure_hpa | max_wind_kt | wind_radius_50kt_dir | wind_radius_50kt_long_nm | wind_radius_50kt_short_nm | wind_radius_30kt_dir | wind_radius_30kt_long_nm | wind_radius_30kt_short_nm | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2501 | WUTIP | 2025-06-10 12:00:00 | 2 | Tropical Depression | 15.0 | 114.2 | 998 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
1 | 2501 | WUTIP | 2025-06-10 18:00:00 | 2 | Tropical Depression | 15.2 | 114.3 | 996 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
2 | 2501 | WUTIP | 2025-06-11 00:00:00 | 2 | Tropical Depression | 16.2 | 113.9 | 996 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
3 | 2501 | WUTIP | 2025-06-11 06:00:00 | 2 | Tropical Depression | 16.6 | 112.6 | 994 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
4 | 2501 | WUTIP | 2025-06-11 12:00:00 | 3 | Tropical Storm | 16.6 | 111.3 | 992 | 35.0 | NaN | NaN | NaN | 9.0 | 180.0 | 180.0 |
你也可以查看完整汇总表,了解每个台风的起止时间、最低气压和路径点数量。
summary_2025
输出:
international_number | name | start_time | end_time | track_points | min_pressure_hpa | max_wind_kt | max_latitude | min_latitude | max_longitude | min_longitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2501 | WUTIP | 2025-06-10 12:00:00 | 2025-06-15 18:00:00 | 22 | 980 | 55.0 | 28.1 | 15.0 | 120.7 | 108.2 |
1 | 2502 | SEPAT | 2025-06-21 06:00:00 | 2025-06-26 12:00:00 | 22 | 1004 | 35.0 | 38.6 | 20.9 | 147.0 | 139.5 |
2 | 2503 | MUN | 2025-07-01 00:00:00 | 2025-07-11 00:00:00 | 41 | 990 | 50.0 | 47.9 | 21.8 | 183.3 | 145.1 |
3 | 2504 | DANAS | 2025-07-03 00:00:00 | 2025-07-11 00:00:00 | 33 | 965 | 75.0 | 27.9 | 19.2 | 122.4 | 113.6 |
4 | 2505 | NARI | 2025-07-09 18:00:00 | 2025-07-17 00:00:00 | 35 | 990 | 45.0 | 48.5 | 19.2 | 183.7 | 140.0 |
5 | 2506 | WIPHA | 2025-07-16 06:00:00 | 2025-07-22 18:00:00 | 27 | 975 | 60.0 | 21.9 | 14.1 | 130.7 | 102.4 |
6 | 2507 | FRANCISCO | 2025-07-22 06:00:00 | 2025-07-26 12:00:00 | 18 | 990 | 40.0 | 26.7 | 17.6 | 132.9 | 119.6 |
7 | 2508 | CO-MAY | 2025-07-22 00:00:00 | 2025-08-03 06:00:00 | 50 | 975 | 60.0 | 34.8 | 16.3 | 129.6 | 117.5 |
8 | 2509 | KROSA | 2025-07-23 12:00:00 | 2025-08-05 06:00:00 | 61 | 965 | 75.0 | 44.2 | 12.3 | 182.7 | 141.3 |
9 | 2510 | BAILU | 2025-07-30 18:00:00 | 2025-08-07 12:00:00 | 32 | 994 | 35.0 | 44.6 | 24.4 | 180.1 | 129.6 |
10 | 2511 | PODUL | 2025-08-05 18:00:00 | 2025-08-15 00:00:00 | 38 | 960 | 80.0 | 26.3 | 18.0 | 149.0 | 108.6 |
11 | 2512 | LINGLING | 2025-08-17 06:00:00 | 2025-08-23 00:00:00 | 28 | 994 | 45.0 | 33.0 | 21.3 | 134.9 | 126.4 |
12 | 2513 | KAJIKI | 2025-08-21 06:00:00 | 2025-08-26 18:00:00 | 23 | 950 | 80.0 | 20.3 | 15.3 | 126.4 | 100.6 |
13 | 2514 | NONGFA | 2025-08-27 18:00:00 | 2025-08-30 18:00:00 | 13 | 996 | 40.0 | 18.3 | 14.8 | 116.8 | 102.2 |
14 | 2515 | PEIPAH | 2025-09-02 12:00:00 | 2025-09-10 06:00:00 | 42 | 980 | 45.0 | 40.2 | 20.8 | 181.5 | 130.9 |
15 | 2516 | TAPAH | 2025-09-05 18:00:00 | 2025-09-08 18:00:00 | 13 | 980 | 60.0 | 24.5 | 17.6 | 117.9 | 110.0 |
16 | 2517 | MITAG | 2025-09-15 12:00:00 | 2025-09-20 18:00:00 | 22 | 992 | 50.0 | 23.8 | 13.4 | 125.5 | 112.1 |
17 | 2518 | RAGASA | 2025-09-16 18:00:00 | 2025-09-25 12:00:00 | 36 | 905 | 110.0 | 21.8 | 13.2 | 137.7 | 106.3 |
18 | 2519 | NEOGURI | 2025-09-17 12:00:00 | 2025-09-29 18:00:00 | 50 | 925 | 100.0 | 44.7 | 22.6 | 181.2 | 150.9 |
19 | 2520 | BUALOI | 2025-09-22 18:00:00 | 2025-09-30 00:00:00 | 30 | 975 | 65.0 | 22.0 | 9.9 | 137.3 | 99.2 |
20 | 2521 | MATMO | 2025-09-30 18:00:00 | 2025-10-06 06:00:00 | 23 | 975 | 70.0 | 22.1 | 13.7 | 132.9 | 106.6 |
21 | 2522 | HALONG | 2025-10-03 00:00:00 | 2025-10-11 12:00:00 | 38 | 935 | 100.0 | 45.4 | 20.1 | 182.1 | 136.9 |
22 | 2523 | NAKRI | 2025-10-06 12:00:00 | 2025-10-16 06:00:00 | 44 | 970 | 70.0 | 39.7 | 13.2 | 185.5 | 130.3 |
23 | 2524 | FENGSHEN | 2025-10-15 12:00:00 | 2025-10-23 00:00:00 | 31 | 990 | 50.0 | 18.3 | 12.7 | 139.7 | 108.7 |
24 | 2525 | KALMAEGI | 2025-10-31 12:00:00 | 2025-11-07 18:00:00 | 30 | 950 | 90.0 | 16.1 | 7.9 | 140.5 | 103.2 |
25 | 2526 | FUNG-WONG | 2025-11-04 06:00:00 | 2025-11-13 18:00:00 | 39 | 935 | 95.0 | 26.2 | 8.3 | 144.8 | 118.2 |
26 | 2527 | KOTO | 2025-11-23 12:00:00 | 2025-12-03 12:00:00 | 41 | 970 | 70.0 | 14.7 | 7.4 | 131.6 | 108.9 |
这里采用 PlateCarree(central_longitude=180) 投影,让跨越 180° 经线的路径更自然。
def plot_all_typhoon_tracks(df: pd.DataFrame, output_path: Path):
storm_ids = df['international_number'].drop_duplicates().tolist()
colors = plt.cm.nipy_spectral(np.linspace(0.05, 0.95, len(storm_ids)))
fig = plt.figure(figsize=(15, 11))
ax = plt.axes(projection=ccrs.PlateCarree(central_longitude=180))
ax.set_extent([100, 260, 0, 60], crs=ccrs.PlateCarree())
ax.add_feature(cfeature.OCEAN.with_scale('110m'), facecolor='#dceefb', zorder=0)
ax.add_feature(cfeature.LAND.with_scale('110m'), facecolor='#f7f3e8', edgecolor='none', zorder=0)
ax.add_feature(cfeature.COASTLINE.with_scale('110m'), linewidth=0.7)
ax.add_feature(cfeature.BORDERS.with_scale('110m'), linewidth=0.4, linestyle=':')
gl = ax.gridlines(
crs=ccrs.PlateCarree(),
draw_labels=True,
linewidth=0.5,
color='gray',
alpha=0.5,
linestyle='--'
)
gl.top_labels = False
gl.right_labels = False
for color, (storm_id, track) in zip(colors, df.groupby('international_number')):
track = track.sort_values('time')
storm_name = track['name'].iloc[0]
label = f'{storm_id} {storm_name}'
ax.plot(
track['longitude'],
track['latitude'],
color=color,
linewidth=1.8,
transform=ccrs.PlateCarree(),
label=label,
)
ax.scatter(
track['longitude'].iloc[0],
track['latitude'].iloc[0],
color=color,
s=20,
marker='o',
transform=ccrs.PlateCarree(),
zorder=3,
)
ax.scatter(
track['longitude'].iloc[-1],
track['latitude'].iloc[-1],
color=color,
s=24,
marker='s',
transform=ccrs.PlateCarree(),
zorder=3,
)
ax.set_title('JMA Best Track of All 2025 Typhoons', fontsize=18, pad=16)
legend = ax.legend(
loc='upper center',
bbox_to_anchor=(0.5, -0.08),
ncol=3,
fontsize=8,
frameon=True,
title='Storm ID / Name'
)
legend.get_title().set_fontsize(9)
fig.tight_layout()
if output_path isnotNone:
fig.savefig(output_path, dpi=180, bbox_inches='tight')
print(f'图片已保存到:{output_path.resolve()}')
return fig, ax
fig, ax = plot_all_typhoon_tracks(df_2025, OUTPUT_FIG)
plt.show()
输出:
图片已保存到:/home/mw/project/jma_2025_typhoon_tracks.png

2025 年全部台风路径图
除了"每个台风一条颜色"之外,另一种常见画法是:按每个时次的 JMA 强度等级为路径着色。
这样可以更直观地看出台风在生命周期中的增强与减弱过程。
这里的实现思路是:
grade_code"作为线段颜色;GRADE_STYLE = {
2: {'label': 'TD', 'color': '#9ecae1'},
3: {'label': 'TS', 'color': '#3182bd'},
4: {'label': 'STS', 'color': '#31a354'},
5: {'label': 'TY', 'color': '#de2d26'},
6: {'label': 'Extratropical', 'color': '#756bb1'},
7: {'label': 'Entering area', 'color': '#fd8d3c'},
9: {'label': 'TS or higher', 'color': '#fdae6b'},
}
def plot_typhoon_tracks_by_intensity(df: pd.DataFrame, output_path: Path):
fig = plt.figure(figsize=(15, 11))
ax = plt.axes(projection=ccrs.PlateCarree(central_longitude=180))
ax.set_extent([100, 260, 0, 60], crs=ccrs.PlateCarree())
ax.add_feature(cfeature.OCEAN.with_scale('110m'), facecolor='#dceefb', zorder=0)
ax.add_feature(cfeature.LAND.with_scale('110m'), facecolor='#f7f3e8', edgecolor='none', zorder=0)
ax.add_feature(cfeature.COASTLINE.with_scale('110m'), linewidth=0.7)
ax.add_feature(cfeature.BORDERS.with_scale('110m'), linewidth=0.4, linestyle=':')
gl = ax.gridlines(
crs=ccrs.PlateCarree(),
draw_labels=True,
linewidth=0.5,
color='gray',
alpha=0.5,
linestyle='--'
)
gl.top_labels = False
gl.right_labels = False
for storm_id, track in df.groupby('international_number'):
track = track.sort_values('time').reset_index(drop=True)
for i in range(len(track) - 1):
row = track.iloc[i]
next_row = track.iloc[i + 1]
style = GRADE_STYLE.get(int(row['grade_code']), {'label': 'Unknown', 'color': '#636363'})
ax.plot(
[row['longitude'], next_row['longitude']],
[row['latitude'], next_row['latitude']],
color=style['color'],
linewidth=1.8,
alpha=0.95,
transform=ccrs.PlateCarree(),
zorder=2,
)
point_colors = [GRADE_STYLE.get(int(code), {'label': 'Unknown', 'color': '#636363'})['color'] for code in track['grade_code']]
ax.scatter(
track['longitude'],
track['latitude'],
c=point_colors,
s=12,
edgecolors='none',
transform=ccrs.PlateCarree(),
zorder=3,
)
observed_codes = sorted(df['grade_code'].dropna().astype(int).unique())
legend_handles = [
Line2D([0], [0], color=GRADE_STYLE[code]['color'], lw=2.5, label=f"{code} {GRADE_STYLE[code]['label']}")
for code in observed_codes if code in GRADE_STYLE
]
ax.set_title('JMA Best Track of All 2025 Typhoons (Colored by Intensity)', fontsize=18, pad=16)
legend = ax.legend(
handles=legend_handles,
loc='upper center',
bbox_to_anchor=(0.5, -0.08),
ncol=3,
fontsize=9,
frameon=True,
title='JMA grade code / intensity'
)
legend.get_title().set_fontsize(10)
fig.tight_layout()
if output_path isnotNone:
fig.savefig(output_path, dpi=180, bbox_inches='tight')
print(f'图片已保存到:{output_path.resolve()}')
return fig, ax
OUTPUT_FIG_INTENSITY = Path('jma_2025_typhoon_tracks_by_intensity.png')
fig, ax = plot_typhoon_tracks_by_intensity(df_2025, OUTPUT_FIG_INTENSITY)
plt.show()
输出:
图片已保存到:/home/mw/project/jma_2025_typhoon_tracks_by_intensity.png

按强度等级着色的 2025 年台风路径图
JMA Best Track 数据行在风速字段之后,还包含了 50 kt 与 30 kt 风圈信息(当台风达到一定强度时才会记录)。
字段位置 | 内容 | 说明 |
|---|---|---|
parts[7] | H IIIII | 50 kt 风圈:第 1 位 H 为方向代码,后 4 位 IIIII 为最长半径(nm) |
parts[8] | JJJJ | 50 kt 风圈最短半径(nm) |
parts[9] | K LLLLL | 30 kt 风圈:第 1 位 K 为方向代码,后 4 位 LLLLL 为最长半径(nm) |
parts[10] | MMMM | 30 kt 风圈最短半径(nm) |
方向代码:
0=无 /9=同心圆、1=NE、2=E、3=SE、4=S、5=SW、6=W、7=NW、8=N。
下面以 2025 年台风 KROSA(国际编号 2505)为例——它有 54 条风圈记录,且同时出现过 30 kt 与 50 kt 风圈——绘制其路径,并在每个时次叠加风圈范围。
为保持图形清晰,这里用圆近似表示风圈(半径取最长半径,并做纬度方向余弦修正),30 kt 用蓝色半透明填充,50 kt 用红色半透明填充。
from matplotlib.patches import Ellipse
import math
def nm_to_deg(radius_nm: float, lat: float, axis: str = 'ns') -> float:
"""将海里半径转换为地理度数(PlateCarree 数据坐标)。"""
if axis == 'ns': # 南北方向
return radius_nm / 60.0
else: # 东西方向,需除以 cos(lat)
return radius_nm / (60.0 * math.cos(math.radians(lat)))
def plot_wind_radii_for_storm(df: pd.DataFrame, storm_name: str, output_path: Path):
storm = df[df['name'] == storm_name].sort_values('time').reset_index(drop=True)
if storm.empty:
raise ValueError(f'未找到台风 {storm_name}')
fig = plt.figure(figsize=(14, 12))
ax = plt.axes(projection=ccrs.PlateCarree(central_longitude=180))
# 自动设置范围:路径四周留 3° 边距
lon_min, lon_max = storm['longitude'].min() - 3, storm['longitude'].max() + 3
lat_min, lat_max = storm['latitude'].min() - 3, storm['latitude'].max() + 3
ax.set_extent([lon_min, lon_max, lat_min, lat_max], crs=ccrs.PlateCarree())
ax.add_feature(cfeature.OCEAN.with_scale('110m'), facecolor='#dceefb', zorder=0)
ax.add_feature(cfeature.LAND.with_scale('110m'), facecolor='#f7f3e8', edgecolor='none', zorder=0)
ax.add_feature(cfeature.COASTLINE.with_scale('110m'), linewidth=0.7)
ax.add_feature(cfeature.BORDERS.with_scale('110m'), linewidth=0.4, linestyle=':')
gl = ax.gridlines(
crs=ccrs.PlateCarree(),
draw_labels=True,
linewidth=0.5,
color='gray',
alpha=0.5,
linestyle='--'
)
gl.top_labels = False
gl.right_labels = False
# 绘制台风路径
ax.plot(
storm['longitude'],
storm['latitude'],
color='#2c2c2c',
linewidth=1.5,
marker='o',
markersize=3,
transform=ccrs.PlateCarree(),
zorder=4,
label='Track'
)
# 在每个时次绘制风圈
for _, row in storm.iterrows():
lat = row['latitude']
lon = row['longitude']
# 30 kt 风圈(蓝色)
r30 = row['wind_radius_30kt_long_nm']
if pd.notna(r30) and r30 > 0:
# 用 Ellipse 近似圆,修正 cos(lat) 压缩
width = 2 * nm_to_deg(r30, lat, axis='ew')
height = 2 * nm_to_deg(r30, lat, axis='ns')
ellipse = Ellipse(
xy=(lon, lat),
width=width,
height=height,
angle=0,
facecolor='#3182bd',
edgecolor='#2166ac',
alpha=0.18,
linewidth=0.8,
transform=ccrs.PlateCarree(),
zorder=2,
)
ax.add_patch(ellipse)
# 50 kt 风圈(红色)
r50 = row['wind_radius_50kt_long_nm']
if pd.notna(r50) and r50 > 0:
width = 2 * nm_to_deg(r50, lat, axis='ew')
height = 2 * nm_to_deg(r50, lat, axis='ns')
ellipse = Ellipse(
xy=(lon, lat),
width=width,
height=height,
angle=0,
facecolor='#de2d26',
edgecolor='#a50f15',
alpha=0.22,
linewidth=0.8,
transform=ccrs.PlateCarree(),
zorder=3,
)
ax.add_patch(ellipse)
# 标注台风名称(路径中点附近)
mid_idx = len(storm) // 2
ax.text(
storm.iloc[mid_idx]['longitude'] + 0.8,
storm.iloc[mid_idx]['latitude'] + 0.8,
storm_name,
fontsize=14,
fontweight='bold',
color='#2c2c2c',
transform=ccrs.PlateCarree(),
zorder=5,
)
ax.set_title(
f'JMA Best Track of {storm_name} (2025) with 30 kt / 50 kt Wind Radii',
fontsize=16,
pad=14
)
# 自定义图例
from matplotlib.patches import Patch
legend_handles = [
Line2D([0], [0], color='#2c2c2c', marker='o', markersize=5, linewidth=1.5, label='Best track'),
Patch(facecolor='#3182bd', edgecolor='#2166ac', alpha=0.4, label='30 kt wind radius'),
Patch(facecolor='#de2d26', edgecolor='#a50f15', alpha=0.5, label='50 kt wind radius'),
]
ax.legend(
handles=legend_handles,
loc='upper center',
bbox_to_anchor=(0.5, -0.06),
ncol=3,
fontsize=10,
frameon=True,
)
fig.tight_layout()
return fig, ax
OUTPUT_FIG_RADII = Path('jma_2025_krosa_wind_radii.png')
fig, ax = plot_wind_radii_for_storm(df_2025, 'KROSA', OUTPUT_FIG_RADII)
plt.show()
(此处应显示 KROSA 台风路径图,路径上用蓝色半透明椭圆表示 30 kt 风圈范围,红色半透明椭圆表示 50 kt 风圈范围。)

image
到这里,你已经学会了如何读取并绘制 JMA 的台风路径数据了。
你们平时还经常碰到哪些比较奇怪的数据呢,可评论区留言讨论。