rsplot 0.3.1.dev3__py3-none-any.whl → 0.3.2.dev4__py3-none-any.whl

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/cli.py CHANGED
@@ -45,14 +45,18 @@ def _callback(
45
45
  @app.command()
46
46
  def raster(
47
47
  region: str = typer.Argument(
48
- ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
48
+ ...,
49
+ help="区域名称;支持逗号分隔多个省市县街道,如 '中山市/东区街道,中山市/南区街道',无需配置",
49
50
  ),
50
51
  date_arg: str = typer.Argument(
51
52
  ...,
52
- help="单日 YYYYMMDD 走单日路径;带 --days 或写成 YYYYMMDD-YYYYMMDD "
53
+ help="GEMS: 北京时间 YYYYMMDDHHMM 精确扫描,YYYYMMDD 日均;单日 YYYYMMDD 走单日路径;带 --days 或写成 YYYYMMDD-YYYYMMDD "
53
54
  "时切换为窗口均值(HCHO 单日覆盖率低,月均更可读)",
54
55
  ),
55
56
  # --- optional overrides ---
57
+ sensor: str = typer.Option(
58
+ "tropomi", "--sensor", help="卫星来源: tropomi / gems"
59
+ ),
56
60
  product: str = typer.Option(
57
61
  "o3",
58
62
  "--product",
@@ -92,7 +96,11 @@ def raster(
92
96
  help="输出文件路径,不指定则弹窗显示。",
93
97
  ),
94
98
  dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
95
- res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
99
+ res: float | None = typer.Option(
100
+ None,
101
+ "--res",
102
+ help="网格分辨率 (度);GEMS 绘图仅控制显示,分析网格固定 0.05°",
103
+ ),
96
104
  basemap: str | None = typer.Option(
97
105
  None,
98
106
  "--basemap",
@@ -106,7 +114,9 @@ def raster(
106
114
  None, "--smooth", help="高斯平滑 sigma"
107
115
  ),
108
116
  title: str | None = typer.Option(None, "--title", help="自定义标题"),
109
- qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
117
+ qa: float | None = typer.Option(
118
+ None, "--qa", help="TROPOMI QA 阈值 (0-1),默认取配置;GEMS 不适用"
119
+ ),
110
120
  cf: float = typer.Option(
111
121
  0.5,
112
122
  "--cf",
@@ -128,6 +138,7 @@ def raster(
128
138
  region=region,
129
139
  date_arg=date_arg,
130
140
  product=product,
141
+ sensor=sensor,
131
142
  days=days,
132
143
  n_min=n_min,
133
144
  level=level,
@@ -620,20 +631,29 @@ def trend(
620
631
  @app.command()
621
632
  def overlay(
622
633
  region: str = typer.Argument(
623
- ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
634
+ ...,
635
+ help="区域名称;支持逗号分隔多个省市县街道,如 '中山市/东区街道,中山市/南区街道',无需配置",
624
636
  ),
625
637
  datetime_str: str = typer.Argument(
626
638
  ...,
627
- help="YYYYMMDDHH (精确小时) 或 YYYYMMDD (自动取最新小时);"
639
+ help="GEMS: 北京时间 YYYYMMDD 日均(需 --station-datetime),或 YYYYMMDDHHMM 精确扫描(自动匹配最近站点整点);TROPOMI: YYYYMMDDHH (精确小时) 或 YYYYMMDD (自动取最新小时);"
628
640
  "日期部分用于卫星,小时部分用于站点",
629
641
  ),
630
642
  # --- satellite product ---
643
+ sensor: str = typer.Option(
644
+ "tropomi", "--sensor", help="卫星来源: tropomi / gems"
645
+ ),
631
646
  product: str = typer.Option(
632
647
  "o3",
633
648
  "--product",
634
649
  "-p",
635
650
  help="卫星产品类型: no2 / o3 / o3pr / hcho",
636
651
  ),
652
+ station_datetime: str | None = typer.Option(
653
+ None,
654
+ "--station-datetime",
655
+ help="GEMS 叠加站点时间,北京时间 YYYYMMDDHH",
656
+ ),
637
657
  # --- station variable ---
638
658
  var: str | None = typer.Option(
639
659
  None,
@@ -665,7 +685,11 @@ def overlay(
665
685
  help="输出文件路径,不指定则弹窗显示。",
666
686
  ),
667
687
  dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
668
- res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
688
+ res: float | None = typer.Option(
689
+ None,
690
+ "--res",
691
+ help="网格分辨率 (度);GEMS 绘图仅控制显示,分析网格固定 0.05°",
692
+ ),
669
693
  basemap: str | None = typer.Option(
670
694
  None,
671
695
  "--basemap",
@@ -682,7 +706,9 @@ def overlay(
682
706
  smooth: float | None = typer.Option(
683
707
  None, "--smooth", help="高斯平滑 sigma"
684
708
  ),
685
- qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
709
+ qa: float | None = typer.Option(
710
+ None, "--qa", help="TROPOMI QA 阈值 (0-1),默认取配置;GEMS 不适用"
711
+ ),
686
712
  cf: float = typer.Option(
687
713
  0.5,
688
714
  "--cf",
@@ -708,7 +734,9 @@ def overlay(
708
734
  run_overlay(
709
735
  region=region,
710
736
  datetime_str=datetime_str,
737
+ station_datetime=station_datetime,
711
738
  product=product,
739
+ sensor=sensor,
712
740
  var=var,
713
741
  level=level,
714
742
  raster_dir=raster_dir,
@@ -746,6 +774,9 @@ def recent(
746
774
  "--source",
747
775
  help="数据源: raster / station",
748
776
  ),
777
+ sensor: str = typer.Option(
778
+ "tropomi", "--sensor", help="卫星来源: tropomi / gems"
779
+ ),
749
780
  product: str = typer.Option(
750
781
  "o3",
751
782
  "--product",
@@ -776,8 +807,14 @@ def recent(
776
807
  "-o",
777
808
  help="输出 JSON 路径",
778
809
  ),
779
- res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
780
- qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
810
+ res: float | None = typer.Option(
811
+ None,
812
+ "--res",
813
+ help="TROPOMI 网格分辨率 (度);GEMS 统计固定使用 0.05°,忽略此选项",
814
+ ),
815
+ qa: float | None = typer.Option(
816
+ None, "--qa", help="TROPOMI QA 阈值 (0-1),默认取配置;GEMS 不适用"
817
+ ),
781
818
  cf: float = typer.Option(
782
819
  0.5,
783
820
  "--cf",
@@ -800,6 +837,7 @@ def recent(
800
837
  days=days,
801
838
  source=source,
802
839
  product=product,
840
+ sensor=sensor,
803
841
  var=var,
804
842
  level=level,
805
843
  data_dir=data_dir,
rsplot/commands/common.py CHANGED
@@ -3,13 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import math
6
- from typing import TYPE_CHECKING
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, ParamSpec, TypeVar
8
+
9
+ import typer
7
10
 
8
11
  if TYPE_CHECKING:
9
12
  import geopandas as gpd
10
13
  from rich.console import Console
11
14
 
15
+ from rsplot.config import AppConfig
12
16
  from rsplot.geo.boundaries import RegionInfo
17
+ from rsplot.readers import ProductInfo
13
18
 
14
19
 
15
20
  FORECAST_STATION_PAIR: dict[str, str] = {
@@ -110,3 +115,61 @@ def print_failed_dates(
110
115
  console.print(f" - {date}: {message}")
111
116
  if len(items) > max_items:
112
117
  console.print(f" ... 另有 {len(items) - max_items} 天")
118
+
119
+
120
+ def resolve_satellite(
121
+ product: str,
122
+ sensor: str,
123
+ qa: float | None,
124
+ cfg: AppConfig,
125
+ console: Console,
126
+ ) -> tuple[ProductInfo, float]:
127
+ """Resolve source and QA policy before opening regional/data files."""
128
+ from rsplot.readers import get_product_info
129
+
130
+ try:
131
+ info = get_product_info(product, sensor=sensor)
132
+ if info.sensor == "gems" and qa is not None:
133
+ raise ValueError(
134
+ "--qa 仅用于 TROPOMI;GEMS 使用最终质量标记 = 0 "
135
+ "及 SZA/VZA < 70°,云量可通过 --cf 设置。"
136
+ )
137
+ threshold = (
138
+ 0.5
139
+ if info.sensor == "gems"
140
+ else qa
141
+ if qa is not None
142
+ else cfg.qa_threshold
143
+ )
144
+ validate_unit_interval(threshold, "--qa")
145
+ except ValueError as error:
146
+ console.print(f"[red]{error}[/red]")
147
+ raise typer.Exit(1) from error
148
+ return info, threshold
149
+
150
+
151
+ def satellite_filter_label(sensor: str, qa: float, cf: float) -> str:
152
+ """Describe the actual filter rather than a synthetic GEMS QA value."""
153
+ quality = (
154
+ "最终质量标记 = 0, SZA/VZA < 70°" if sensor == "gems" else f"QA > {qa}"
155
+ )
156
+ return f"{quality}, 云量 <= {cf}"
157
+
158
+
159
+ _P = ParamSpec("_P")
160
+ _R = TypeVar("_R")
161
+
162
+
163
+ def satellite_errors(function: Callable[_P, _R]) -> Callable[_P, _R]:
164
+ """Render expected satellite input/data errors in the CLI's style."""
165
+ from functools import wraps
166
+
167
+ @wraps(function)
168
+ def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R:
169
+ try:
170
+ return function(*args, **kwargs)
171
+ except (ValueError, FileNotFoundError, OSError) as error:
172
+ kwargs["console"].print(f"[red]{error}[/red]")
173
+ raise typer.Exit(1) from error
174
+
175
+ return wrapped
@@ -53,6 +53,7 @@ def run_forecast(
53
53
  from rsplot.geo.boundaries import resolve_region
54
54
  from rsplot.geo.processing import (
55
55
  buffered_extent,
56
+ prepare_display_grid,
56
57
  prepare_existing_grid,
57
58
  )
58
59
  from rsplot.plotting.colormaps import STATION_CONCENTRATION_CMAP
@@ -151,10 +152,22 @@ def run_forecast(
151
152
  info.geometry,
152
153
  fill_gaps=False,
153
154
  )
154
- if not np.isfinite(prepared.values).any():
155
+ display_source = prepared.unmasked_values
156
+ display_grid = prepare_display_grid(
157
+ prepared.lon,
158
+ prepared.lat,
159
+ display_source,
160
+ info.geometry,
161
+ smooth_sigma=smooth,
162
+ )
163
+ if not np.isfinite(display_grid).any():
155
164
  console.print("[red]目标区域内没有有效预报网格。[/red]")
156
165
  raise typer.Exit(1)
157
- if smooth is not None and smooth > 0:
166
+ if (
167
+ smooth is not None
168
+ and smooth > 0
169
+ and np.isfinite(prepared.values).any()
170
+ ):
158
171
  console.print(f" → 高斯平滑 sigma={smooth}")
159
172
  prepared = prepare_existing_grid(
160
173
  prepared.lon,
@@ -170,7 +183,12 @@ def run_forecast(
170
183
  f"({prepared.n_valid_region}/{prepared.total_region_pixels})"
171
184
  )
172
185
 
173
- auto_min, auto_max = auto_forecast_vrange(prepared.values, var_info)
186
+ auto_min, auto_max = auto_forecast_vrange(
187
+ prepared.values
188
+ if np.isfinite(prepared.values).any()
189
+ else display_grid,
190
+ var_info,
191
+ )
174
192
  use_vmin = vmin if vmin is not None else auto_min
175
193
  use_vmax = vmax if vmax is not None else auto_max
176
194
  try:
@@ -289,7 +307,7 @@ def run_forecast(
289
307
  plot_raster(
290
308
  prepared.lon,
291
309
  prepared.lat,
292
- prepared.values,
310
+ display_grid,
293
311
  info,
294
312
  dpi=use_dpi,
295
313
  cmap=use_cmap,
@@ -306,7 +324,7 @@ def run_forecast(
306
324
  plot_overlay(
307
325
  prepared.lon,
308
326
  prepared.lat,
309
- prepared.values,
327
+ display_grid,
310
328
  station_data,
311
329
  info,
312
330
  raster_cmap=use_cmap,
@@ -330,6 +348,7 @@ def run_forecast(
330
348
  LON=prepared.lon,
331
349
  LAT=prepared.lat,
332
350
  grid=prepared.values,
351
+ display_grid=display_grid,
333
352
  vmin=use_vmin,
334
353
  vmax=use_vmax,
335
354
  output=output,
@@ -10,6 +10,9 @@ import typer
10
10
  from rsplot.commands.common import (
11
11
  load_fallback_city_boundaries,
12
12
  parse_basemap,
13
+ resolve_satellite,
14
+ satellite_errors,
15
+ satellite_filter_label,
13
16
  validate_color_range,
14
17
  validate_non_negative,
15
18
  validate_positive,
@@ -29,11 +32,14 @@ PRODUCT_STATION_PAIR = {
29
32
  }
30
33
 
31
34
 
35
+ @satellite_errors
32
36
  def run_overlay(
33
37
  *,
34
38
  region: str,
35
39
  datetime_str: str,
36
40
  product: str,
41
+ sensor: str = "tropomi",
42
+ station_datetime: str | None = None,
37
43
  var: str | None,
38
44
  level: str | None,
39
45
  raster_dir: str | None,
@@ -53,15 +59,34 @@ def run_overlay(
53
59
  ) -> None:
54
60
  """Execute satellite/station overlay orchestration."""
55
61
  from rsplot.config import STATION_VAR_META, get_exceedance_threshold
56
- from rsplot.geo.boundaries import resolve_region
57
- from rsplot.geo.processing import buffered_extent, prepare_swath_grid
62
+ from rsplot.geo.boundaries import resolve_regions
63
+ from rsplot.geo.processing import (
64
+ buffered_extent,
65
+ prepare_display_grid,
66
+ prepare_existing_grid,
67
+ prepare_swath_grid,
68
+ )
58
69
  from rsplot.plotting.overlay import plot_overlay
59
70
  from rsplot.plotting.styles import setup_fonts
60
- from rsplot.readers import auto_vrange, get_product_info
71
+ from rsplot.readers import auto_vrange
61
72
  from rsplot.readers.guokong import mask_stations_to_region, read_stations
62
73
  from rsplot.results import build_overlay_result, emit_result
63
74
 
64
75
  cfg = AppConfig.load()
76
+ prod_info, use_qa = resolve_satellite(product, sensor, qa, cfg, console)
77
+ sensor = prod_info.sensor
78
+ pairing = None
79
+ station_request = datetime_str
80
+ if sensor == "gems":
81
+ from rsplot.readers.gems import match_station_hour
82
+
83
+ station_request, pairing = match_station_hour(
84
+ datetime_str, station_datetime
85
+ )
86
+ if product.lower() == "hcho" and var is None:
87
+ raise ValueError("GEMS HCHO 叠加需通过 --var 显式指定站点变量。")
88
+ elif station_datetime is not None:
89
+ raise ValueError("--station-datetime 在此命令中仅用于 GEMS。")
65
90
  setup_fonts(cfg.font_dir)
66
91
 
67
92
  if var is None:
@@ -71,7 +96,9 @@ def run_overlay(
71
96
  console.print(f"[red]未知站点变量 '{var}'[/red]。可用: {available}")
72
97
  raise typer.Exit(1)
73
98
 
74
- if not datetime_str.isdigit() or len(datetime_str) not in (8, 10):
99
+ if sensor != "gems" and (
100
+ not datetime_str.isdigit() or len(datetime_str) not in (8, 10)
101
+ ):
75
102
  console.print(
76
103
  f"[red]datetime 参数 '{datetime_str}' 格式不正确[/red],"
77
104
  "请使用 YYYYMMDD 或 YYYYMMDDHH。"
@@ -80,8 +107,15 @@ def run_overlay(
80
107
  date = datetime_str[:8]
81
108
 
82
109
  console.print(f"[bold]解析区域:[/bold] {region}")
83
- info = resolve_region(region, cfg, level_override=level)
110
+ info = resolve_regions(region, cfg, level_override=level)
84
111
  console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
112
+ if info.members:
113
+ console.print(
114
+ " → 成员: "
115
+ + "、".join(
116
+ member.qualified_name or member.name for member in info.members
117
+ )
118
+ )
85
119
  console.print(
86
120
  f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
87
121
  f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
@@ -89,7 +123,6 @@ def run_overlay(
89
123
 
90
124
  params = info.params
91
125
  use_dpi = dpi if dpi is not None else cfg.dpi
92
- prod_info = get_product_info(product)
93
126
  product_defaults = prod_info.defaults_for_level(info.level)
94
127
  use_res = (
95
128
  res
@@ -99,7 +132,6 @@ def run_overlay(
99
132
  else params.res
100
133
  )
101
134
  use_cmap = cmap if cmap is not None else prod_info.cmap
102
- use_qa = qa if qa is not None else cfg.qa_threshold
103
135
  use_smooth = (
104
136
  smooth
105
137
  if smooth is not None
@@ -129,36 +161,117 @@ def run_overlay(
129
161
  )
130
162
 
131
163
  use_raster_dir = (
132
- raster_dir if raster_dir is not None else cfg.get_data_dir(product)
164
+ raster_dir
165
+ if raster_dir is not None
166
+ else cfg.get_data_dir(prod_info.data_key or product)
133
167
  )
134
168
  reader = prod_info.reader_cls()
135
169
  reader.cloud_fraction_max = cf
170
+ analysis_res = use_res
171
+ if sensor == "gems":
172
+ from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
173
+
174
+ analysis_res = GEMS_ANALYSIS_RESOLUTION
175
+ console.print(
176
+ f" → GEMS 分析网格 {analysis_res}°,显示网格 {use_res}°"
177
+ )
136
178
  read_extent = buffered_extent(info.extent, 1.0)
137
179
  console.print(
138
180
  f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})"
139
181
  )
140
- console.print(f" 日期: {date}, QA > {use_qa}, CF <= {cf}")
141
- swath = reader.read(
142
- use_raster_dir,
143
- date,
144
- read_extent,
145
- qa_threshold=use_qa,
146
- )
147
182
  console.print(
148
- f" → [green]{swath.n_pixels}[/green] 有效像元 "
149
- f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
183
+ f" 时间: {datetime_str}, {satellite_filter_label(sensor, use_qa, cf)}"
150
184
  )
185
+ statistics_grid = None
186
+ if sensor == "gems":
187
+ from rsplot.temporal import read_scan_mean
151
188
 
152
- console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
153
- prepared = prepare_swath_grid(
154
- swath.lon,
155
- swath.lat,
156
- swath.values,
157
- read_extent,
158
- use_res,
159
- info.geometry,
160
- smooth_sigma=use_smooth,
161
- )
189
+ batch = read_scan_mean(
190
+ reader=reader,
191
+ data_dir=use_raster_dir,
192
+ request=datetime_str,
193
+ extent=read_extent,
194
+ res=analysis_res,
195
+ )
196
+ prepared = prepare_existing_grid(
197
+ batch.LON,
198
+ batch.LAT,
199
+ batch.grid,
200
+ info.geometry,
201
+ fill_gaps=False,
202
+ )
203
+ display_source = prepared.unmasked_values
204
+ statistics_grid = prepared.values.copy()
205
+ swath_meta = {
206
+ "n_pixels": batch.n_pixels,
207
+ "n_files": batch.n_files,
208
+ "n_broken": batch.n_broken,
209
+ "resolution_deg": use_res,
210
+ "cloud_fraction_max": cf,
211
+ **batch.metadata,
212
+ }
213
+ else:
214
+ swath = reader.read(
215
+ use_raster_dir,
216
+ date,
217
+ read_extent,
218
+ qa_threshold=use_qa,
219
+ )
220
+ console.print(
221
+ f" → [green]{swath.n_pixels}[/green] 有效像元 "
222
+ f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
223
+ )
224
+
225
+ console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
226
+ prepared = prepare_swath_grid(
227
+ swath.lon,
228
+ swath.lat,
229
+ swath.values,
230
+ read_extent,
231
+ use_res,
232
+ info.geometry,
233
+ smooth_sigma=use_smooth,
234
+ )
235
+ display_source = prepared.unmasked_values
236
+ swath_meta = {
237
+ "n_pixels": int(swath.n_pixels),
238
+ "n_files": int(swath.n_files),
239
+ "n_broken": int(swath.n_broken),
240
+ "qa_threshold": use_qa,
241
+ "cloud_fraction_max": cf,
242
+ "resolution_deg": use_res,
243
+ }
244
+ display_lon, display_lat = prepared.lon, prepared.lat
245
+ display_support = None
246
+ if sensor == "gems":
247
+ from rsplot.geo.footprints import prepare_footprint_display
248
+
249
+ display = prepare_footprint_display(
250
+ prepared.lon,
251
+ prepared.lat,
252
+ display_source,
253
+ info.geometry,
254
+ analysis_res=analysis_res,
255
+ display_res=use_res,
256
+ smooth_sigma=use_smooth,
257
+ )
258
+ display_lon, display_lat = display.lon, display.lat
259
+ display_grid, display_support = display.values, display.support
260
+ swath_meta["resolution_deg"] = analysis_res
261
+ swath_meta["analysis_resolution_deg"] = analysis_res
262
+ swath_meta["display_resolution_deg"] = use_res
263
+ swath_meta["display_support"] = "fixed_analysis_valid_cells"
264
+ else:
265
+ display_grid = prepare_display_grid(
266
+ prepared.lon,
267
+ prepared.lat,
268
+ display_source,
269
+ info.geometry,
270
+ smooth_sigma=use_smooth,
271
+ )
272
+ if not np.isfinite(display_grid).any():
273
+ console.print("[red]目标区域没有相交的有效卫星网格。[/red]")
274
+ raise typer.Exit(1)
162
275
  console.print(
163
276
  f" → 网格 {prepared.values.shape}, "
164
277
  f"区域内覆盖率 {prepared.coverage_pct:.1f}% "
@@ -171,7 +284,12 @@ def run_overlay(
171
284
  use_vmin = vmin if vmin is not None else prod_info.vmin
172
285
  use_vmax = vmax if vmax is not None else prod_info.vmax
173
286
  else:
174
- auto_min, auto_max = auto_vrange(prepared.values, prod_info)
287
+ auto_min, auto_max = auto_vrange(
288
+ prepared.values
289
+ if np.isfinite(prepared.values).any()
290
+ else display_grid,
291
+ prod_info,
292
+ )
175
293
  use_vmin = vmin if vmin is not None else auto_min
176
294
  use_vmax = vmax if vmax is not None else auto_max
177
295
  try:
@@ -185,16 +303,16 @@ def run_overlay(
185
303
  station_dir if station_dir is not None else cfg.get_data_dir("guokong")
186
304
  )
187
305
  console.print(f"\n[bold]读取站点数据:[/bold] {use_station_dir}")
188
- console.print(f" 时间: {datetime_str}, 变量: {var}")
306
+ console.print(f" 时间: {station_request}, 变量: {var}")
189
307
  station_extent = buffered_extent(info.extent, 0.5)
190
308
  data, resolved_dt = read_stations(
191
309
  use_station_dir,
192
- datetime_str,
310
+ station_request,
193
311
  var,
194
312
  extent=station_extent,
195
313
  )
196
314
  data = mask_stations_to_region(data, info.geometry)
197
- if resolved_dt != datetime_str:
315
+ if resolved_dt != station_request:
198
316
  console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
199
317
  console.print(
200
318
  f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
@@ -209,6 +327,17 @@ def run_overlay(
209
327
  f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
210
328
  )
211
329
 
330
+ if output is None and sensor == "gems":
331
+ output = (
332
+ f"/tmp/rsplot_overlay_gems_{info.name}_{product}_"
333
+ f"{datetime_str}_{resolved_dt}.png"
334
+ )
335
+ if title is None and sensor == "gems":
336
+ period = "daily mean" if len(datetime_str) == 8 else "scan"
337
+ title = (
338
+ f"GEMS {product.upper()} {datetime_str} {period} CST + "
339
+ f"Station {var} {resolved_dt} CST - {info.name}"
340
+ )
212
341
  if output is None:
213
342
  output = f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
214
343
  unit = STATION_VAR_META[var][0]
@@ -216,9 +345,9 @@ def run_overlay(
216
345
 
217
346
  console.print("\n[bold]绘图中...[/bold]")
218
347
  plot_overlay(
219
- prepared.lon,
220
- prepared.lat,
221
- prepared.values,
348
+ display_lon,
349
+ display_lat,
350
+ display_grid,
222
351
  data,
223
352
  info,
224
353
  raster_cmap=use_cmap,
@@ -233,6 +362,7 @@ def run_overlay(
233
362
  hcho_cmap_file=cfg.hcho_cmap_file,
234
363
  title=title,
235
364
  output=output,
365
+ **({"coverage_geometry": display_support} if sensor == "gems" else {}),
236
366
  )
237
367
  console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
238
368
 
@@ -243,15 +373,14 @@ def run_overlay(
243
373
  prod_info=prod_info,
244
374
  LON=prepared.lon,
245
375
  LAT=prepared.lat,
246
- grid=prepared.values,
247
- swath_meta={
248
- "n_pixels": int(swath.n_pixels),
249
- "n_files": int(swath.n_files),
250
- "n_broken": int(swath.n_broken),
251
- "qa_threshold": use_qa,
252
- "cloud_fraction_max": cf,
253
- "resolution_deg": use_res,
254
- },
376
+ grid=statistics_grid
377
+ if statistics_grid is not None
378
+ else prepared.values,
379
+ swath_meta=swath_meta,
380
+ time_matching=pairing,
381
+ display_grid=display_grid,
382
+ display_lon=display_lon,
383
+ display_lat=display_lat,
255
384
  raster_vmin=use_vmin,
256
385
  raster_vmax=use_vmax,
257
386
  data=data,