rsplot 0.2.5__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {rsplot-0.2.5 → rsplot-0.2.6}/.gitignore +1 -0
  2. {rsplot-0.2.5 → rsplot-0.2.6}/PKG-INFO +18 -1
  3. {rsplot-0.2.5 → rsplot-0.2.6}/README.md +17 -0
  4. {rsplot-0.2.5 → rsplot-0.2.6}/pyproject.toml +1 -1
  5. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/cli.py +410 -15
  6. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/config.py +14 -1
  7. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/gridding.py +27 -4
  8. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/colormaps.py +9 -6
  9. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/station.py +113 -9
  10. rsplot-0.2.6/src/rsplot/readers/era5_wind.py +232 -0
  11. rsplot-0.2.6/src/rsplot/readers/forecast.py +257 -0
  12. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/recent.py +19 -5
  13. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/results.py +122 -3
  14. rsplot-0.2.5/src/rsplot/plotting/meteo.py +0 -422
  15. rsplot-0.2.5/src/rsplot/readers/era5.py +0 -477
  16. {rsplot-0.2.5 → rsplot-0.2.6}/LICENSE +0 -0
  17. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/__init__.py +0 -0
  18. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/__main__.py +0 -0
  19. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/fnr.py +0 -0
  20. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/__init__.py +0 -0
  21. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/boundaries.py +0 -0
  22. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/__init__.py +0 -0
  23. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/fnr.py +0 -0
  24. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/overlay.py +0 -0
  25. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/raster.py +0 -0
  26. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/styles.py +0 -0
  27. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/__init__.py +0 -0
  28. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/base.py +0 -0
  29. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/guokong.py +0 -0
  30. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_hcho.py +0 -0
  31. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_no2.py +0 -0
  32. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_o3.py +0 -0
  33. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_o3pr.py +0 -0
  34. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/tiles/__init__.py +0 -0
  35. {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/tiles/tianditu.py +0 -0
@@ -280,3 +280,4 @@ tags
280
280
  logs/
281
281
  .vscode/
282
282
  .claude
283
+ tests/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rsplot
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: CLI tool for plotting Remote Sensing data, designed for Agent.
5
5
  Project-URL: Repository, https://git.lug.ustc.edu.cn/yaoyhu/rsplot
6
6
  Project-URL: GitHub, https://github.com/yaoyhu/rsplot
@@ -280,6 +280,8 @@ o3 = "/path/to/O3"
280
280
  o3pr = "/path/to/O3PR"
281
281
  hcho = "/path/to/HCHO"
282
282
  guokong = "/path/to/guokong_combine"
283
+ era5 = "/path/to/era5"
284
+ forecast = "/path/to/forecast"
283
285
 
284
286
  # Optional satellite basemap key. Leave commented unless you have a real key.
285
287
  # [tianditu]
@@ -303,6 +305,8 @@ export RSPLOT_DATA_DIR_O3="/path/to/O3"
303
305
  export RSPLOT_DATA_DIR_O3PR="/path/to/O3PR"
304
306
  export RSPLOT_DATA_DIR_HCHO="/path/to/HCHO"
305
307
  export RSPLOT_DATA_DIR_GUOKONG="/path/to/guokong_combine"
308
+ export RSPLOT_DATA_DIR_ERA5="/path/to/era5"
309
+ export RSPLOT_DATA_DIR_FORECAST="/path/to/forecast"
306
310
  export RSPLOT_HCHO_CMAP_FILE="/path/to/256x3_rgb_table.txt"
307
311
  ```
308
312
 
@@ -330,9 +334,22 @@ rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
330
334
  # Plot an explicit TROPOMI date range.
331
335
  rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
332
336
 
337
+ # Plot gridded air-quality forecast initialized at a specific hour.
338
+ rsplot forecast 安徽 2026063000 --lead 24 --var O3 -o forecast_anhui_o3.png
339
+
340
+ # Date-only forecast input automatically selects the latest initialized hour.
341
+ rsplot forecast 合肥 20260630 --lead 72 --var PM2.5 -o forecast_hefei_pm25.png
342
+
333
343
  # Plot national monitoring station observations.
334
344
  rsplot station 合肥 2026041415 --var O3 -o station_hefei_o3.png
335
345
 
346
+ # Overlay hourly ERA5 10 m wind vectors on station observations.
347
+ # Station timestamps default to Asia/Shanghai and are matched to ERA5 UTC.
348
+ rsplot station 合肥 2026041415 --var O3 --wind-level 10m -o station_wind_hefei_o3.png
349
+
350
+ # Pressure-level wind is also supported (1000/925/850/700/500 hPa).
351
+ rsplot station 安徽 2026041415 --var PM2.5 --wind-level 850 -o station_wind_anhui_pm25.png
352
+
336
353
  # Overlay satellite raster data and station observations.
337
354
  rsplot overlay 合肥 2026041415 -p no2 --var NO2 -o overlay_hefei_no2.png
338
355
 
@@ -53,6 +53,8 @@ o3 = "/path/to/O3"
53
53
  o3pr = "/path/to/O3PR"
54
54
  hcho = "/path/to/HCHO"
55
55
  guokong = "/path/to/guokong_combine"
56
+ era5 = "/path/to/era5"
57
+ forecast = "/path/to/forecast"
56
58
 
57
59
  # Optional satellite basemap key. Leave commented unless you have a real key.
58
60
  # [tianditu]
@@ -76,6 +78,8 @@ export RSPLOT_DATA_DIR_O3="/path/to/O3"
76
78
  export RSPLOT_DATA_DIR_O3PR="/path/to/O3PR"
77
79
  export RSPLOT_DATA_DIR_HCHO="/path/to/HCHO"
78
80
  export RSPLOT_DATA_DIR_GUOKONG="/path/to/guokong_combine"
81
+ export RSPLOT_DATA_DIR_ERA5="/path/to/era5"
82
+ export RSPLOT_DATA_DIR_FORECAST="/path/to/forecast"
79
83
  export RSPLOT_HCHO_CMAP_FILE="/path/to/256x3_rgb_table.txt"
80
84
  ```
81
85
 
@@ -103,9 +107,22 @@ rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
103
107
  # Plot an explicit TROPOMI date range.
104
108
  rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
105
109
 
110
+ # Plot gridded air-quality forecast initialized at a specific hour.
111
+ rsplot forecast 安徽 2026063000 --lead 24 --var O3 -o forecast_anhui_o3.png
112
+
113
+ # Date-only forecast input automatically selects the latest initialized hour.
114
+ rsplot forecast 合肥 20260630 --lead 72 --var PM2.5 -o forecast_hefei_pm25.png
115
+
106
116
  # Plot national monitoring station observations.
107
117
  rsplot station 合肥 2026041415 --var O3 -o station_hefei_o3.png
108
118
 
119
+ # Overlay hourly ERA5 10 m wind vectors on station observations.
120
+ # Station timestamps default to Asia/Shanghai and are matched to ERA5 UTC.
121
+ rsplot station 合肥 2026041415 --var O3 --wind-level 10m -o station_wind_hefei_o3.png
122
+
123
+ # Pressure-level wind is also supported (1000/925/850/700/500 hPa).
124
+ rsplot station 安徽 2026041415 --var PM2.5 --wind-level 850 -o station_wind_anhui_pm25.png
125
+
109
126
  # Overlay satellite raster data and station observations.
110
127
  rsplot overlay 合肥 2026041415 -p no2 --var NO2 -o overlay_hefei_no2.png
111
128
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  description = "CLI tool for plotting Remote Sensing data, designed for Agent."
5
5
  authors = [
6
6
  { name = "Yaoyao Hu", email = "yaoyhu@mail.ustc.edu.cn" },
@@ -152,6 +152,7 @@ def raster(
152
152
  from rsplot.geo.gridding import (
153
153
  fill_nan_gaps,
154
154
  grid_data,
155
+ grid_coverage_in_region,
155
156
  mask_to_region,
156
157
  smooth_grid,
157
158
  )
@@ -360,8 +361,6 @@ def raster(
360
361
  "coverage_days": coverage_days,
361
362
  }
362
363
 
363
- coverage = float(np.isfinite(grid_masked).mean()) * 100
364
- console.print(f" → 网格 {grid_masked.shape}, 覆盖率 {coverage:.1f}%")
365
364
  if not np.isfinite(grid_masked).any():
366
365
  console.print(
367
366
  "[red]窗口均值全区无有效像元,可能原因:窗口内覆盖不足,"
@@ -374,6 +373,13 @@ def raster(
374
373
  grid_masked = smooth_grid(
375
374
  grid_masked, info.geometry, LON, LAT, sigma=use_smooth
376
375
  )
376
+ coverage, n_valid_region, total_region_pixels = grid_coverage_in_region(
377
+ LON, LAT, grid_masked, info.geometry
378
+ )
379
+ console.print(
380
+ f" → 网格 {grid_masked.shape}, 区域内覆盖率 {coverage:.1f}% "
381
+ f"({n_valid_region}/{total_region_pixels})"
382
+ )
377
383
 
378
384
  # --- 6. Determine vmin/vmax (CLI > product default > auto) ---
379
385
  if vmin is not None and vmax is not None:
@@ -439,6 +445,254 @@ def raster(
439
445
  emit_result(result, output)
440
446
 
441
447
 
448
+ @app.command()
449
+ def forecast(
450
+ region: str = typer.Argument(
451
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
452
+ ),
453
+ datetime_str: str = typer.Argument(
454
+ ..., help="起报时次 YYYYMMDDHH,或 YYYYMMDD 自动取当天最新小时"
455
+ ),
456
+ var: str = typer.Option(
457
+ "o3",
458
+ "--var",
459
+ "-v",
460
+ help="预报变量: PM2.5 / PM10 / SO2 / NO2 / CO / O3",
461
+ ),
462
+ lead: int = typer.Option(
463
+ 24,
464
+ "--lead",
465
+ help="预报时效: 6 / 12 / 24 / 48 / 72 小时",
466
+ ),
467
+ level: str | None = typer.Option(
468
+ None,
469
+ "--level",
470
+ "-l",
471
+ help="强制指定级别: country / key_region / province / city / county",
472
+ ),
473
+ data_dir: str | None = typer.Option(
474
+ None,
475
+ "--data-dir",
476
+ "-d",
477
+ help="预报数据目录 (覆盖配置中的 forecast 路径)",
478
+ ),
479
+ output: str | None = typer.Option(
480
+ None,
481
+ "--output",
482
+ "-o",
483
+ help="输出文件路径",
484
+ ),
485
+ dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
486
+ basemap: str | None = typer.Option(
487
+ None,
488
+ "--basemap",
489
+ "-b",
490
+ help="底图控制: satellite / none (覆盖级别默认)",
491
+ ),
492
+ vmin: float | None = typer.Option(None, "--vmin", help="Colorbar 最小值"),
493
+ vmax: float | None = typer.Option(None, "--vmax", help="Colorbar 最大值"),
494
+ cmap: str | None = typer.Option(None, "--cmap", help="Colormap 名称"),
495
+ smooth: float | None = typer.Option(
496
+ None, "--smooth", help="高斯平滑 sigma"
497
+ ),
498
+ title: str | None = typer.Option(None, "--title", help="自定义标题"),
499
+ ) -> None:
500
+ """绘制空气质量预报栅格图。
501
+
502
+ 示例:
503
+ rsplot forecast 安徽 2026063000 --lead 24 --var O3
504
+ rsplot forecast 合肥 20260630 --lead 72 --var PM2.5
505
+ """
506
+ from pathlib import Path
507
+
508
+ from rsplot.geo.boundaries import resolve_region
509
+ from rsplot.geo.gridding import (
510
+ grid_coverage_in_region,
511
+ mask_to_region,
512
+ smooth_grid,
513
+ )
514
+ from rsplot.plotting.colormaps import STATION_CONCENTRATION_CMAP
515
+ from rsplot.plotting.raster import plot_raster
516
+ from rsplot.plotting.styles import setup_fonts
517
+ from rsplot.readers.forecast import (
518
+ auto_forecast_vrange,
519
+ get_forecast_var_info,
520
+ read_forecast,
521
+ )
522
+ from rsplot.results import build_forecast_result, emit_result
523
+
524
+ # --- 1. Load config ---
525
+ cfg = AppConfig.load()
526
+ setup_fonts(cfg.font_dir)
527
+
528
+ # --- 2. Resolve region ---
529
+ console.print(f"[bold]解析区域:[/bold] {region}")
530
+ info = resolve_region(region, cfg, level_override=level)
531
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
532
+ console.print(
533
+ f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
534
+ f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
535
+ )
536
+
537
+ # --- 3. Merge params ---
538
+ params = info.params
539
+ use_dpi = dpi if dpi is not None else cfg.dpi
540
+ use_cmap = cmap if cmap is not None else STATION_CONCENTRATION_CMAP
541
+
542
+ if basemap is not None:
543
+ use_basemap: bool | None = basemap.lower() == "satellite"
544
+ else:
545
+ use_basemap = None
546
+ _warn_missing_tianditu_key(
547
+ basemap=use_basemap,
548
+ default_basemap=params.basemap,
549
+ tianditu_key=cfg.tianditu_key,
550
+ )
551
+
552
+ if smooth is not None and smooth < 0:
553
+ console.print("[red]--smooth 必须大于等于 0。[/red]")
554
+ raise typer.Exit(1)
555
+ if vmin is not None and vmax is not None and vmax <= vmin:
556
+ console.print("[red]--vmax 必须大于 --vmin。[/red]")
557
+ raise typer.Exit(1)
558
+
559
+ try:
560
+ var_info = get_forecast_var_info(var)
561
+ except ValueError as e:
562
+ console.print(f"[red]{e}[/red]")
563
+ raise typer.Exit(1) from e
564
+
565
+ try:
566
+ use_data_dir = (
567
+ data_dir
568
+ if data_dir is not None
569
+ else cfg.get_data_dir("forecast")
570
+ )
571
+ except ValueError as e:
572
+ console.print(f"[red]{e}[/red]")
573
+ raise typer.Exit(1) from e
574
+
575
+ # --- 4. Read forecast grid ---
576
+ buf = 0.5
577
+ read_extent = (
578
+ info.extent[0] - buf,
579
+ info.extent[1] + buf,
580
+ info.extent[2] - buf,
581
+ info.extent[3] + buf,
582
+ )
583
+ console.print(
584
+ f"\n[bold]读取预报数据:[/bold] {use_data_dir} "
585
+ f"(变量: {var_info.label}, 时效: {lead}h)"
586
+ )
587
+ console.print(f" 起报时次: {datetime_str}")
588
+ try:
589
+ forecast_data, resolved_dt = read_forecast(
590
+ use_data_dir,
591
+ datetime_str,
592
+ lead,
593
+ var,
594
+ read_extent,
595
+ )
596
+ except (FileNotFoundError, ValueError) as e:
597
+ console.print(f"[red]{e}[/red]")
598
+ raise typer.Exit(1) from e
599
+
600
+ if resolved_dt != datetime_str:
601
+ console.print(f" → 自动选择最新起报: [cyan]{resolved_dt}[/cyan]")
602
+ console.print(
603
+ f" → 预报有效时次: [cyan]{forecast_data.valid_datetime}[/cyan], "
604
+ f"有效网格: [green]{forecast_data.n_valid}[/green] / "
605
+ f"{forecast_data.n_cells}"
606
+ )
607
+
608
+ # --- 5. Mask, optional smooth, and color scale ---
609
+ grid_masked = mask_to_region(
610
+ forecast_data.lon,
611
+ forecast_data.lat,
612
+ forecast_data.values,
613
+ info.geometry,
614
+ )
615
+ if not np.isfinite(grid_masked).any():
616
+ console.print("[red]目标区域内没有有效预报网格。[/red]")
617
+ raise typer.Exit(1)
618
+
619
+ if smooth is not None and smooth > 0:
620
+ console.print(f" → 高斯平滑 sigma={smooth}")
621
+ grid_masked = smooth_grid(
622
+ grid_masked,
623
+ info.geometry,
624
+ forecast_data.lon,
625
+ forecast_data.lat,
626
+ sigma=smooth,
627
+ )
628
+ coverage, n_valid_region, total_region_pixels = grid_coverage_in_region(
629
+ forecast_data.lon,
630
+ forecast_data.lat,
631
+ grid_masked,
632
+ info.geometry,
633
+ )
634
+ console.print(
635
+ f" → 区域网格 {grid_masked.shape}, 区域内覆盖率 {coverage:.1f}% "
636
+ f"({n_valid_region}/{total_region_pixels})"
637
+ )
638
+
639
+ auto_min, auto_max = auto_forecast_vrange(grid_masked, var_info)
640
+ use_vmin = vmin if vmin is not None else auto_min
641
+ use_vmax = vmax if vmax is not None else auto_max
642
+ if use_vmax <= use_vmin:
643
+ console.print("[red]色标范围无效:vmax 必须大于 vmin。[/red]")
644
+ raise typer.Exit(1)
645
+ console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
646
+
647
+ # --- 6. Plot ---
648
+ if output is None:
649
+ output = (
650
+ f"/tmp/rsplot_forecast_{info.name}_{forecast_data.variable}_"
651
+ f"{forecast_data.init_datetime}_f{forecast_data.lead_hour}.png"
652
+ )
653
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
654
+
655
+ if title is None:
656
+ title = (
657
+ f"Forecast {forecast_data.label} - {info.name} "
658
+ f"(valid {forecast_data.valid_datetime}, "
659
+ f"+{forecast_data.lead_hour}h)"
660
+ )
661
+
662
+ console.print("\n[bold]绘图中...[/bold]")
663
+ plot_raster(
664
+ forecast_data.lon,
665
+ forecast_data.lat,
666
+ grid_masked,
667
+ info,
668
+ dpi=use_dpi,
669
+ cmap=use_cmap,
670
+ vmin=use_vmin,
671
+ vmax=use_vmax,
672
+ basemap=use_basemap,
673
+ tianditu_key=cfg.tianditu_key,
674
+ hcho_cmap_file=cfg.hcho_cmap_file,
675
+ title=title,
676
+ output=output,
677
+ colorbar_label=var_info.colorbar_label,
678
+ )
679
+
680
+ console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
681
+
682
+ # --- 7. Emit structured result for downstream agents ---
683
+ result = build_forecast_result(
684
+ region=info,
685
+ data=forecast_data,
686
+ LON=forecast_data.lon,
687
+ LAT=forecast_data.lat,
688
+ grid=grid_masked,
689
+ vmin=use_vmin,
690
+ vmax=use_vmax,
691
+ output=output,
692
+ )
693
+ emit_result(result, output)
694
+
695
+
442
696
  @app.command()
443
697
  def station(
444
698
  region: str = typer.Argument(
@@ -479,20 +733,68 @@ def station(
479
733
  "-b",
480
734
  help="底图控制: satellite / none",
481
735
  ),
736
+ wind_level: str | None = typer.Option(
737
+ None,
738
+ "--wind-level",
739
+ help="叠加 ERA5 风场层次: 10m / 100m / 1000 / 925 / 850 / 700 / 500",
740
+ ),
741
+ wind_data_dir: str | None = typer.Option(
742
+ None,
743
+ "--wind-data-dir",
744
+ help="ERA5 数据目录 (覆盖配置中的 era5 路径)",
745
+ ),
746
+ station_timezone: str = typer.Option(
747
+ "Asia/Shanghai",
748
+ "--station-timezone",
749
+ help="国控文件名时次的 IANA 时区,用于匹配 ERA5 UTC",
750
+ ),
751
+ wind_stride: int | None = typer.Option(
752
+ None,
753
+ "--wind-stride",
754
+ help="风矢量抽稀步长;默认根据区域和网格大小自动选择",
755
+ ),
756
+ wind_scale: float | None = typer.Option(
757
+ None,
758
+ "--wind-scale",
759
+ help="Matplotlib quiver scale;不指定时自动缩放",
760
+ ),
761
+ wind_width: float = typer.Option(
762
+ 0.004,
763
+ "--wind-width",
764
+ help="风箭头宽度;默认 0.004",
765
+ ),
766
+ wind_cmap: str = typer.Option(
767
+ "viridis", "--wind-cmap", help="风速色标名称"
768
+ ),
769
+ wind_vmin: float | None = typer.Option(
770
+ None, "--wind-vmin", help="风速色标最小值"
771
+ ),
772
+ wind_vmax: float | None = typer.Option(
773
+ None, "--wind-vmax", help="风速色标最大值"
774
+ ),
482
775
  title: str | None = typer.Option(None, "--title", help="自定义标题"),
483
776
  ) -> None:
484
- """绘制国控站点散点图。
777
+ """绘制国控站点散点图,可选叠加 ERA5 风场。
485
778
 
486
779
  示例:
487
780
  rsplot station 安徽 20260414 (自动取当天最新小时)
488
781
  rsplot station 安徽 2026041415 (精确到15时)
489
782
  rsplot station 中国 20260414 --var PM2.5_24h
490
783
  rsplot station YRD 2026041415 --var NO2 -o logs/yrd_no2.png
784
+ rsplot station 合肥 2026041415 --var O3 --wind-level 10m
491
785
  """
492
- from rsplot.config import STATION_VAR_META, get_exceedance_threshold
786
+ from rsplot.config import (
787
+ STATION_VAR_LABELS,
788
+ STATION_VAR_META,
789
+ get_exceedance_threshold,
790
+ )
493
791
  from rsplot.geo.boundaries import resolve_region
494
- from rsplot.plotting.station import plot_station
792
+ from rsplot.plotting.station import plot_station, wind_stride_for_shape
495
793
  from rsplot.plotting.styles import setup_fonts
794
+ from rsplot.readers.era5_wind import (
795
+ mask_wind_to_region,
796
+ read_era5_wind,
797
+ )
496
798
  from rsplot.readers.guokong import mask_stations_to_region, read_stations
497
799
 
498
800
  # --- 1. Load config ---
@@ -546,7 +848,67 @@ def station(
546
848
  f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
547
849
  )
548
850
 
549
- # --- 6. Plot ---
851
+ # --- 6. Optional ERA5 wind overlay ---
852
+ wind_data = None
853
+ use_wind_stride = None
854
+ if wind_level is not None:
855
+ if wind_stride is not None and wind_stride < 1:
856
+ console.print("[red]--wind-stride 必须大于等于 1。[/red]")
857
+ raise typer.Exit(1)
858
+ if wind_scale is not None and wind_scale <= 0:
859
+ console.print("[red]--wind-scale 必须大于 0。[/red]")
860
+ raise typer.Exit(1)
861
+ if wind_width <= 0:
862
+ console.print("[red]--wind-width 必须大于 0。[/red]")
863
+ raise typer.Exit(1)
864
+ if (
865
+ wind_vmin is not None
866
+ and wind_vmax is not None
867
+ and wind_vmax <= wind_vmin
868
+ ):
869
+ console.print("[red]--wind-vmax 必须大于 --wind-vmin。[/red]")
870
+ raise typer.Exit(1)
871
+
872
+ use_wind_data_dir = (
873
+ wind_data_dir
874
+ if wind_data_dir is not None
875
+ else cfg.get_data_dir("era5")
876
+ )
877
+ console.print(
878
+ f"\n[bold]读取 ERA5 风场:[/bold] {use_wind_data_dir}"
879
+ )
880
+ console.print(
881
+ f" 站点时次: {resolved_dt} ({station_timezone}), "
882
+ f"层次: {wind_level}"
883
+ )
884
+ try:
885
+ wind_data = read_era5_wind(
886
+ use_wind_data_dir,
887
+ resolved_dt,
888
+ wind_level,
889
+ read_extent,
890
+ station_timezone=station_timezone,
891
+ )
892
+ wind_data = mask_wind_to_region(wind_data, info.geometry)
893
+ except (FileNotFoundError, ValueError) as e:
894
+ console.print(f"[red]{e}[/red]")
895
+ raise typer.Exit(1) from e
896
+ if wind_data.n_valid == 0:
897
+ console.print("[red]目标区域内没有有效 ERA5 风矢量。[/red]")
898
+ raise typer.Exit(1)
899
+
900
+ use_wind_stride = (
901
+ wind_stride
902
+ if wind_stride is not None
903
+ else wind_stride_for_shape(wind_data.speed.shape, info.level)
904
+ )
905
+ console.print(
906
+ f" → ERA5 UTC 时次: [cyan]{wind_data.datetime_utc}[/cyan], "
907
+ f"有效网格: [green]{wind_data.n_valid}[/green], "
908
+ f"抽稀步长: {use_wind_stride}"
909
+ )
910
+
911
+ # --- 7. Plot ---
550
912
  use_dpi = dpi if dpi is not None else cfg.dpi
551
913
 
552
914
  if basemap is not None:
@@ -560,27 +922,47 @@ def station(
560
922
  )
561
923
 
562
924
  unit = STATION_VAR_META[var][0]
563
- colorbar_label = f"{var} ({unit})" if unit else var
925
+ display_var = STATION_VAR_LABELS.get(var, var)
926
+ colorbar_label = f"{display_var} ({unit})" if unit else display_var
927
+ use_title = title
928
+ if use_title is None and display_var != var:
929
+ if wind_data is None:
930
+ use_title = f"{display_var} - {info.name}"
931
+ else:
932
+ use_title = (
933
+ f"{display_var} + ERA5 {wind_data.level} wind - {info.name}"
934
+ )
564
935
 
565
936
  if output is None:
566
- output = f"/tmp/rsplot_station_{info.name}_{var}_{resolved_dt}.png"
937
+ wind_suffix = f"_wind{wind_data.level}" if wind_data is not None else ""
938
+ output = (
939
+ f"/tmp/rsplot_station_{info.name}_{var}_{resolved_dt}"
940
+ f"{wind_suffix}.png"
941
+ )
567
942
 
568
943
  console.print("\n[bold]绘图中...[/bold]")
569
944
  plot_station(
570
945
  data,
571
946
  info,
947
+ wind=wind_data,
948
+ wind_stride=use_wind_stride or 1,
949
+ wind_scale=wind_scale,
950
+ wind_width=wind_width,
951
+ wind_cmap=wind_cmap,
952
+ wind_vmin=wind_vmin,
953
+ wind_vmax=wind_vmax,
572
954
  threshold=threshold,
573
955
  dpi=use_dpi,
574
956
  basemap=use_basemap,
575
957
  tianditu_key=cfg.tianditu_key,
576
- title=title,
958
+ title=use_title,
577
959
  output=output,
578
960
  colorbar_label=colorbar_label,
579
961
  )
580
962
 
581
963
  console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
582
964
 
583
- # --- 7. Emit structured result for downstream agents ---
965
+ # --- 8. Emit structured result for downstream agents ---
584
966
  from rsplot.geo.boundaries import _load_city
585
967
  from rsplot.results import build_station_result, emit_result
586
968
 
@@ -592,6 +974,8 @@ def station(
592
974
  unit=unit,
593
975
  cities_gdf=_load_city(cfg.require_geojson_dir()),
594
976
  output=output,
977
+ wind=wind_data,
978
+ wind_stride=use_wind_stride,
595
979
  )
596
980
  emit_result(result, output)
597
981
 
@@ -691,6 +1075,7 @@ def overlay(
691
1075
  from rsplot.geo.gridding import (
692
1076
  fill_nan_gaps,
693
1077
  grid_data,
1078
+ grid_coverage_in_region,
694
1079
  mask_to_region,
695
1080
  smooth_grid,
696
1081
  )
@@ -792,13 +1177,17 @@ def overlay(
792
1177
  )
793
1178
  grid_filled = fill_nan_gaps(LON, LAT, grid_raw)
794
1179
  grid_masked = mask_to_region(LON, LAT, grid_filled, info.geometry)
795
- coverage = float(np.isfinite(grid_masked).mean()) * 100
796
- console.print(f" → 网格 {grid_masked.shape}, 覆盖率 {coverage:.1f}%")
797
-
798
1180
  if use_smooth is not None:
799
1181
  grid_masked = smooth_grid(
800
1182
  grid_masked, info.geometry, LON, LAT, sigma=use_smooth
801
1183
  )
1184
+ coverage, n_valid_region, total_region_pixels = grid_coverage_in_region(
1185
+ LON, LAT, grid_masked, info.geometry
1186
+ )
1187
+ console.print(
1188
+ f" → 网格 {grid_masked.shape}, 区域内覆盖率 {coverage:.1f}% "
1189
+ f"({n_valid_region}/{total_region_pixels})"
1190
+ )
802
1191
 
803
1192
  # --- 8. Raster vmin/vmax ---
804
1193
  if vmin is not None and vmax is not None:
@@ -1180,6 +1569,7 @@ def fnr(
1180
1569
  validate_fnr_thresholds,
1181
1570
  )
1182
1571
  from rsplot.geo.boundaries import resolve_region
1572
+ from rsplot.geo.gridding import grid_coverage_in_region
1183
1573
  from rsplot.plotting.fnr import plot_fnr
1184
1574
  from rsplot.plotting.styles import setup_fonts
1185
1575
  from rsplot.results import build_fnr_result, emit_result
@@ -1302,9 +1692,14 @@ def fnr(
1302
1692
  )
1303
1693
  raise typer.Exit(1)
1304
1694
 
1305
- coverage_pct = 100 * len(finite) / fnr_out.fnr.size
1695
+ coverage_pct, n_valid_region, total_region_pixels = (
1696
+ grid_coverage_in_region(
1697
+ fnr_out.LON, fnr_out.LAT, fnr_out.fnr, info.geometry
1698
+ )
1699
+ )
1306
1700
  console.print(
1307
- f" → FNR 覆盖率 {coverage_pct:.1f}%, "
1701
+ f" → FNR 区域内覆盖率 {coverage_pct:.1f}% "
1702
+ f"({n_valid_region}/{total_region_pixels}), "
1308
1703
  f"中位数 {float(np.median(finite)):.2f}"
1309
1704
  )
1310
1705
 
@@ -22,7 +22,15 @@ else:
22
22
  # ---------------------------------------------------------------------------
23
23
  # Safe built-in path defaults
24
24
  # ---------------------------------------------------------------------------
25
- KNOWN_DATA_PRODUCTS = ("no2", "o3", "o3pr", "hcho", "guokong")
25
+ KNOWN_DATA_PRODUCTS = (
26
+ "no2",
27
+ "o3",
28
+ "o3pr",
29
+ "hcho",
30
+ "guokong",
31
+ "era5",
32
+ "forecast",
33
+ )
26
34
 
27
35
  DEFAULT_DATA_DIRS: dict[str, str] = {}
28
36
  DEFAULT_GEOJSON_DIR = None
@@ -342,6 +350,11 @@ STATION_VAR_META: dict[str, tuple[str, str | None, str | None]] = {
342
350
  "CO_24h": ("mg/m3", "CO", "daily"),
343
351
  }
344
352
 
353
+ STATION_VAR_LABELS: dict[str, str] = {
354
+ "O3_8h": "8h O3",
355
+ "O3_8h_24h": "MDA8 O3",
356
+ }
357
+
345
358
 
346
359
  def get_exceedance_threshold(var_name: str) -> float | None:
347
360
  """Return the exceedance threshold for a station variable, or None."""