首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >代码实战 | JMA 台风最佳路径可视化教程

代码实战 | JMA 台风最佳路径可视化教程

作者头像
用户11172986
发布2026-04-24 19:38:33
发布2026-04-24 19:38:33
940
举报
文章被收录于专栏:气python风雨气python风雨

代码实战 | JMA 台风最佳路径可视化教程

前言

之前调研文献发现很多同学喜欢使用 jma 的台风路径数据,于是有了这篇教程。

本教程演示如何读取日本气象厅的 JMA 最佳路径文件,并绘制 2025 年全部台风路径图。

教程目标:

  1. 解析 JMA best track 文本文件;
  2. 提取 2025 年所有台风的经纬度、气压和时间;
  3. 统计台风基础信息;
  4. 在西北太平洋区域绘制 2025 年全部台风路径。

1. 数据说明

JMA 最佳路径文件由多段台风记录组成:

  • 66666 开头的行为台风头信息
  • 后续多行为该台风的逐时次路径记录
  • 当再次遇到 66666,表示下一个台风开始。

本教程采用的筛选规则是:

  • 读取头信息中的国际编号(如 25012502);
  • 选取国际编号以 25 开头的记录,也就是 2025 年编号的台风
  • 再把对应路径记录整理成 DataFrame,用于绘图和统计。

2. JMA 最佳路径各参数说明(基于 JMA 官方格式)

下面的说明整理自 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

2.1 每个台风的头信息行(Header line)

每个台风开始时都有一行头信息,典型结构如下:

代码语言:javascript
复制
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

2.2 每条时次数据行(Data line)

典型结构如下:

代码语言:javascript
复制
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.3 强度等级代码(Grade code)

官方常见代码说明如下:

代码

含义

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 强度及以上热带气旋)

2.4 风圈方向代码

JMA 风圈方向代码用于描述"最长半径"所在象限或方向:

代码

含义

0

无风圈 / 半径为 0

1

NE

2

E

3

SE

4

S

5

SW

6

W

7

NW

8

N

9

同心圆(各方向相同)

2.5 本教程实际解析了哪些字段?

为了让 notebook 更聚焦于"路径可视化",本教程当前重点解析了以下字段:

  • 时间 time
  • 强度代码 grade_code
  • 中心经纬度 latitude / longitude
  • 中心气压 pressure_hpa
  • 最大持续风速 max_wind_kt

风圈半径、方向代码以及登陆标志在官方格式中同样很重要,但为了保持代码简洁,这里先不纳入主解析流程。


3. 编写解析函数

  • 使用正则表达式解析头信息;
  • 把每条路径记录解析为时间、等级代码、经纬度、中心气压、最大风速等字段。

注意:

  • time:按 yyMMddHH 解析;
  • latitude / longitude:原始数据以 0.1 度 为单位,所以需要除以 10;
  • max_wind_kt:若值为 000,这里视为缺失值;
  • 只保留国际编号以 25 开头的台风。
代码语言:javascript
复制
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')
代码语言:javascript
复制
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

4. 读取并检查 2025 年全部台风数据

代码语言:javascript
复制
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)} 条')

输出:

代码语言:javascript
复制
共解析出台风 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

你也可以查看完整汇总表,了解每个台风的起止时间、最低气压和路径点数量。

代码语言:javascript
复制
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


5. 绘制 2025 年全部台风路径图

这里采用 PlateCarree(central_longitude=180) 投影,让跨越 180° 经线的路径更自然。

代码语言:javascript
复制
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()

输出:

代码语言:javascript
复制
图片已保存到:/home/mw/project/jma_2025_typhoon_tracks.png
2025 年全部台风路径图
2025 年全部台风路径图

2025 年全部台风路径图


6. 按台风强度着色

除了"每个台风一条颜色"之外,另一种常见画法是:按每个时次的 JMA 强度等级为路径着色

这样可以更直观地看出台风在生命周期中的增强与减弱过程。

这里的实现思路是:

  • 仍然按台风分组;
  • 对相邻两个路径点之间的线段,使用"起始点对应的 grade_code"作为线段颜色;
  • 同时把所有路径点也按强度着色;
  • 图例改为展示 JMA grade code / intensity,而不是台风名称列表。
代码语言:javascript
复制
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()

输出:

代码语言:javascript
复制
图片已保存到:/home/mw/project/jma_2025_typhoon_tracks_by_intensity.png
按强度等级着色的 2025 年台风路径图
按强度等级着色的 2025 年台风路径图

按强度等级着色的 2025 年台风路径图


7. 30 kt / 50 kt 风圈解析与可视化

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 用红色半透明填充。

代码语言:javascript
复制
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
image

image

8. 小结

到这里,你已经学会了如何读取并绘制 JMA 的台风路径数据了。

你们平时还经常碰到哪些比较奇怪的数据呢,可评论区留言讨论。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-04-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 气python风雨 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 代码实战 | JMA 台风最佳路径可视化教程
    • 前言
    • 1. 数据说明
    • 2. JMA 最佳路径各参数说明(基于 JMA 官方格式)
      • 2.1 每个台风的头信息行(Header line)
      • 2.2 每条时次数据行(Data line)
      • 2.3 强度等级代码(Grade code)
      • 2.4 风圈方向代码
      • 2.5 本教程实际解析了哪些字段?
    • 3. 编写解析函数
    • 4. 读取并检查 2025 年全部台风数据
    • 5. 绘制 2025 年全部台风路径图
    • 6. 按台风强度着色
    • 7. 30 kt / 50 kt 风圈解析与可视化
    • 8. 小结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档