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.
- {rsplot-0.2.5 → rsplot-0.2.6}/.gitignore +1 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/PKG-INFO +18 -1
- {rsplot-0.2.5 → rsplot-0.2.6}/README.md +17 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/pyproject.toml +1 -1
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/cli.py +410 -15
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/config.py +14 -1
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/gridding.py +27 -4
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/colormaps.py +9 -6
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/station.py +113 -9
- rsplot-0.2.6/src/rsplot/readers/era5_wind.py +232 -0
- rsplot-0.2.6/src/rsplot/readers/forecast.py +257 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/recent.py +19 -5
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/results.py +122 -3
- rsplot-0.2.5/src/rsplot/plotting/meteo.py +0 -422
- rsplot-0.2.5/src/rsplot/readers/era5.py +0 -477
- {rsplot-0.2.5 → rsplot-0.2.6}/LICENSE +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/fnr.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/geo/boundaries.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/fnr.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/__init__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/readers/tropomi_o3pr.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.5 → rsplot-0.2.6}/src/rsplot/tiles/tianditu.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rsplot
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
# ---
|
|
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
|
|
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
|
|
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 = (
|
|
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."""
|