rsplot 0.1.0__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 ADDED
@@ -0,0 +1,1100 @@
1
+ """CLI command definitions for rsplot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from rsplot.config import AppConfig
13
+
14
+ console = Console()
15
+ app = typer.Typer(
16
+ name="rsplot",
17
+ help="CLI tool for Remote sensing raster plotting.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _warn_missing_tianditu_key(
23
+ *,
24
+ basemap: bool | None,
25
+ default_basemap: bool,
26
+ tianditu_key: str | None,
27
+ ) -> None:
28
+ """Warn when a requested TianDiTu basemap cannot be loaded."""
29
+ use_basemap = basemap if basemap is not None else default_basemap
30
+ if use_basemap and not tianditu_key:
31
+ console.print(
32
+ "[yellow]未设置天地图 API key,底图将不会加载。请设置 "
33
+ "TIANDITU_API_KEY,或使用 --basemap none。[/yellow]"
34
+ )
35
+
36
+
37
+ def _default_raster_n_min(product: str, window_n: int) -> int:
38
+ """Return the product-aware default valid-day threshold for raster means.
39
+
40
+ HCHO has much sparser day-to-day coverage than NO2/O3 after QA/cloud
41
+ filtering. Requiring half the window for HCHO makes otherwise useful
42
+ monthly means look holey, so use a softer default while still avoiding
43
+ one-off pixels.
44
+ """
45
+ if product.lower() == "hcho":
46
+ return max(3, math.ceil(window_n * 0.25))
47
+ return max(3, window_n // 2)
48
+
49
+
50
+ @app.callback()
51
+ def _callback() -> None:
52
+ "CLI tool for Remote sensing raster plotting."
53
+
54
+
55
+ @app.command()
56
+ def raster(
57
+ region: str = typer.Argument(
58
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
59
+ ),
60
+ date_arg: str = typer.Argument(
61
+ ...,
62
+ help="单日 YYYYMMDD 走单日路径;带 --days 或写成 YYYYMMDD-YYYYMMDD "
63
+ "时切换为窗口均值(HCHO 单日覆盖率低,月均更可读)",
64
+ ),
65
+ # --- optional overrides ---
66
+ product: str = typer.Option(
67
+ "o3",
68
+ "--product",
69
+ "-p",
70
+ help="产品类型: no2 / o3 / hcho",
71
+ ),
72
+ days: int | None = typer.Option(
73
+ None,
74
+ "--days",
75
+ help="时间窗口天数;不指定且 DATE 为单日时走单日路径。"
76
+ "推荐值: 30 (月均,HCHO 首选) / 7 (周均) / 显式区间用 YYYYMMDD-YYYYMMDD",
77
+ ),
78
+ n_min: int | None = typer.Option(
79
+ None,
80
+ "--n-min",
81
+ help=(
82
+ "窗口模式下每像元最少有效天数。默认: NO2/O3=max(3, 天数//2), "
83
+ "HCHO=max(3, ceil(天数*0.25))"
84
+ ),
85
+ ),
86
+ level: str | None = typer.Option(
87
+ None,
88
+ "--level",
89
+ "-l",
90
+ help="强制指定级别: country / key_region / province / city / county",
91
+ ),
92
+ data_dir: str | None = typer.Option(
93
+ None,
94
+ "--data-dir",
95
+ "-d",
96
+ help="数据目录 (覆盖配置默认值)",
97
+ ),
98
+ output: str | None = typer.Option(
99
+ None,
100
+ "--output",
101
+ "-o",
102
+ help="输出文件路径,不指定则弹窗显示。",
103
+ ),
104
+ dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
105
+ res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
106
+ basemap: str | None = typer.Option(
107
+ None,
108
+ "--basemap",
109
+ "-b",
110
+ help="底图控制: satellite / none (覆盖级别默认)",
111
+ ),
112
+ vmin: float | None = typer.Option(None, "--vmin", help="Colorbar 最小值"),
113
+ vmax: float | None = typer.Option(None, "--vmax", help="Colorbar 最大值"),
114
+ cmap: str | None = typer.Option(None, "--cmap", help="Colormap 名称"),
115
+ smooth: float | None = typer.Option(
116
+ None, "--smooth", help="高斯平滑 sigma"
117
+ ),
118
+ title: str | None = typer.Option(None, "--title", help="自定义标题"),
119
+ qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
120
+ ) -> None:
121
+ """绘制卫星遥感栅格图。
122
+
123
+ 示例:
124
+ rsplot raster 北京 20260108 --dpi 300 --output yrd.png # 单日
125
+ rsplot raster 安徽 20260131 -p hcho --days 30 # 月均(推荐 HCHO)
126
+ rsplot raster 长三角 20260101-20260131 -p no2 # 显式区间
127
+ """
128
+ from rsplot.fnr import compute_window_mean, parse_date_window
129
+ from rsplot.geo.boundaries import resolve_region
130
+ from rsplot.geo.gridding import (
131
+ fill_nan_gaps,
132
+ grid_data,
133
+ mask_to_region,
134
+ smooth_grid,
135
+ )
136
+ from rsplot.plotting.raster import plot_raster
137
+ from rsplot.plotting.styles import setup_fonts
138
+ from rsplot.readers import auto_vrange, get_product_info
139
+ from rsplot.results import build_raster_result, emit_result
140
+
141
+ # --- 0. Decide: single-day or window mode ---
142
+ is_range = "-" in date_arg
143
+ try:
144
+ if is_range:
145
+ dates = parse_date_window(date_arg, default_days=0)
146
+ elif days is not None:
147
+ dates = parse_date_window(date_arg, default_days=days)
148
+ else:
149
+ # Single date + no --days → single-day mode (validated below)
150
+ if len(date_arg) != 8 or not date_arg.isdigit():
151
+ raise ValueError(
152
+ f"日期格式错误: '{date_arg}',"
153
+ f"应为 YYYYMMDD 或 YYYYMMDD-YYYYMMDD"
154
+ )
155
+ dates = [date_arg]
156
+ except ValueError as e:
157
+ console.print(f"[red]{e}[/red]")
158
+ raise typer.Exit(1) from e
159
+
160
+ window_mode = len(dates) > 1
161
+ if window_mode:
162
+ window_n = len(dates)
163
+ use_n_min = (
164
+ n_min
165
+ if n_min is not None
166
+ else _default_raster_n_min(product, window_n)
167
+ )
168
+ if use_n_min > window_n:
169
+ console.print(
170
+ f"[red]--n-min ({use_n_min}) 大于窗口天数 ({window_n})[/red]"
171
+ )
172
+ raise typer.Exit(1)
173
+
174
+ # --- 1. Load config ---
175
+ cfg = AppConfig.load()
176
+ setup_fonts(cfg.font_dir)
177
+
178
+ # --- 2. Resolve region ---
179
+ console.print(f"[bold]解析区域:[/bold] {region}")
180
+ info = resolve_region(region, cfg, level_override=level)
181
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
182
+ console.print(
183
+ f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
184
+ f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
185
+ )
186
+
187
+ # --- 3. Merge params (level defaults + CLI overrides) ---
188
+ params = info.params
189
+ use_res = res if res is not None else params.res
190
+ use_dpi = dpi if dpi is not None else cfg.dpi
191
+ prod_info = get_product_info(product)
192
+ use_cmap = cmap if cmap is not None else prod_info.cmap
193
+ use_qa = qa if qa is not None else cfg.qa_threshold
194
+ use_smooth = smooth if smooth is not None else params.smooth_sigma
195
+
196
+ if basemap is not None:
197
+ use_basemap: bool | None = basemap.lower() == "satellite"
198
+ else:
199
+ use_basemap = None
200
+ _warn_missing_tianditu_key(
201
+ basemap=use_basemap,
202
+ default_basemap=params.basemap,
203
+ tianditu_key=cfg.tianditu_key,
204
+ )
205
+
206
+ # --- 4. Read extent (with buffer to avoid edge artifacts) ---
207
+ buf = 1.0
208
+ read_extent = (
209
+ info.extent[0] - buf,
210
+ info.extent[1] + buf,
211
+ info.extent[2] - buf,
212
+ info.extent[3] + buf,
213
+ )
214
+
215
+ use_data_dir = (
216
+ data_dir if data_dir is not None else cfg.get_data_dir(product)
217
+ )
218
+ reader = prod_info.reader_cls()
219
+ colorbar_label = prod_info.colorbar_label
220
+
221
+ # --- 5. Read + grid (branches on mode) ---
222
+ swath_meta: dict[str, Any]
223
+ window_meta: dict[str, Any] | None = None
224
+
225
+ if not window_mode:
226
+ # ---- Single-day path (unchanged) ----
227
+ console.print(
228
+ f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
229
+ )
230
+ console.print(f" 日期: {dates[0]}, QA > {use_qa}")
231
+ swath = reader.read(
232
+ use_data_dir, dates[0], read_extent, qa_threshold=use_qa
233
+ )
234
+ console.print(
235
+ f" → [green]{swath.n_pixels}[/green] 有效像元 "
236
+ f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
237
+ )
238
+
239
+ console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
240
+ LON, LAT, grid_raw = grid_data(
241
+ swath.lon, swath.lat, swath.values, read_extent, use_res
242
+ )
243
+ grid_filled = fill_nan_gaps(LON, LAT, grid_raw)
244
+ grid_masked = mask_to_region(LON, LAT, grid_filled, info.geometry)
245
+
246
+ swath_meta = {
247
+ "n_pixels": int(swath.n_pixels),
248
+ "n_files": int(swath.n_files),
249
+ "n_broken": int(swath.n_broken),
250
+ "qa_threshold": use_qa,
251
+ "resolution_deg": use_res,
252
+ }
253
+ else:
254
+ # ---- Window-mean path ----
255
+ console.print(
256
+ f"\n[bold]时间窗口:[/bold] {dates[0]} ~ {dates[-1]} "
257
+ f"({window_n} 天,每像元至少 {use_n_min} 天有效)"
258
+ )
259
+ console.print(
260
+ f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
261
+ )
262
+ console.print(f" QA > {use_qa}")
263
+
264
+ win = compute_window_mean(
265
+ reader=reader,
266
+ data_dir=use_data_dir,
267
+ dates=dates,
268
+ extent=read_extent,
269
+ res=use_res,
270
+ qa_threshold=use_qa,
271
+ label=product.upper(),
272
+ console=console,
273
+ )
274
+ n_failed = len(win.dates_failed)
275
+ console.print(
276
+ f" → 有效天数 [green]{len(win.dates_found)}[/green] / "
277
+ f"{window_n} (失败 {n_failed}),累计 "
278
+ f"[green]{win.n_pixels_total}[/green] 像元 "
279
+ f"({win.n_files_total} 文件, {win.n_broken_total} 损坏)"
280
+ )
281
+
282
+ LON, LAT = win.LON, win.LAT
283
+ grid_mean = win.grid.copy()
284
+ # Apply n_min mask: pixels below threshold → NaN
285
+ grid_mean[win.n_valid < use_n_min] = np.nan
286
+
287
+ # Same post-processing as single-day path (fill small gaps from
288
+ # neighbours, then region mask) so visual treatment stays consistent.
289
+ grid_filled = fill_nan_gaps(LON, LAT, grid_mean)
290
+ grid_masked = mask_to_region(LON, LAT, grid_filled, info.geometry)
291
+
292
+ # Per-pixel coverage stats (where any day was valid)
293
+ covered = win.n_valid[win.n_valid > 0]
294
+ coverage_days = {
295
+ "min": int(covered.min()) if len(covered) else 0,
296
+ "max": int(covered.max()) if len(covered) else 0,
297
+ "mean": (
298
+ round(float(covered.mean()), 1) if len(covered) else 0.0
299
+ ),
300
+ }
301
+ if len(covered):
302
+ coverage_days.update(
303
+ {
304
+ "p10": int(np.percentile(covered, 10)),
305
+ "p25": int(np.percentile(covered, 25)),
306
+ "median": int(np.percentile(covered, 50)),
307
+ "p75": int(np.percentile(covered, 75)),
308
+ "p90": int(np.percentile(covered, 90)),
309
+ }
310
+ )
311
+
312
+ swath_meta = {
313
+ "n_pixels_total": int(win.n_pixels_total),
314
+ "n_files_total": int(win.n_files_total),
315
+ "n_broken_total": int(win.n_broken_total),
316
+ "qa_threshold": use_qa,
317
+ "resolution_deg": use_res,
318
+ }
319
+ window_meta = {
320
+ "start": dates[0],
321
+ "end": dates[-1],
322
+ "n_days_requested": window_n,
323
+ "n_days_found": len(win.dates_found),
324
+ "n_min": use_n_min,
325
+ "n_min_default": n_min is None,
326
+ "dates_failed": win.dates_failed,
327
+ "coverage_days": coverage_days,
328
+ }
329
+
330
+ coverage = float(np.isfinite(grid_masked).mean()) * 100
331
+ console.print(f" → 网格 {grid_masked.shape}, 覆盖率 {coverage:.1f}%")
332
+ if not np.isfinite(grid_masked).any():
333
+ console.print(
334
+ "[red]窗口均值全区无有效像元,可能原因:窗口内覆盖不足,"
335
+ "或 --n-min 太严。可放宽 --n-min / 扩大窗口。[/red]"
336
+ )
337
+ raise typer.Exit(1)
338
+
339
+ if use_smooth is not None:
340
+ console.print(f" → 高斯平滑 sigma={use_smooth}")
341
+ grid_masked = smooth_grid(
342
+ grid_masked, info.geometry, LON, LAT, sigma=use_smooth
343
+ )
344
+
345
+ # --- 6. Determine vmin/vmax (CLI > product default > auto) ---
346
+ if vmin is not None and vmax is not None:
347
+ use_vmin, use_vmax = vmin, vmax
348
+ elif prod_info.vmin is not None and prod_info.vmax is not None:
349
+ use_vmin = vmin if vmin is not None else prod_info.vmin
350
+ use_vmax = vmax if vmax is not None else prod_info.vmax
351
+ else:
352
+ auto_min, auto_max = auto_vrange(grid_masked, prod_info)
353
+ use_vmin = vmin if vmin is not None else auto_min
354
+ use_vmax = vmax if vmax is not None else auto_max
355
+
356
+ console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
357
+
358
+ # --- 7. Plot ---
359
+ if output is None:
360
+ if window_mode:
361
+ output = (
362
+ f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
363
+ )
364
+ else:
365
+ output = f"/tmp/rsplot_{info.name}_{dates[0]}.png"
366
+
367
+ if title is None and window_mode:
368
+ title = (
369
+ f"TROPOMI {product.upper()} - {info.name} "
370
+ f"({dates[0]}~{dates[-1]}, {window_n}d mean)"
371
+ )
372
+
373
+ console.print("\n[bold]绘图中...[/bold]")
374
+ plot_raster(
375
+ LON,
376
+ LAT,
377
+ grid_masked,
378
+ info,
379
+ dpi=use_dpi,
380
+ cmap=use_cmap,
381
+ vmin=use_vmin,
382
+ vmax=use_vmax,
383
+ basemap=use_basemap,
384
+ tianditu_key=cfg.tianditu_key,
385
+ title=title,
386
+ output=output,
387
+ colorbar_label=colorbar_label,
388
+ )
389
+
390
+ console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
391
+
392
+ # --- 8. Emit structured result for downstream agents ---
393
+ result = build_raster_result(
394
+ region=info,
395
+ product=product,
396
+ date=dates[0] if not window_mode else dates[-1],
397
+ prod_info=prod_info,
398
+ LON=LON,
399
+ LAT=LAT,
400
+ grid=grid_masked,
401
+ swath_meta=swath_meta,
402
+ vmin=use_vmin,
403
+ vmax=use_vmax,
404
+ output=output,
405
+ window=window_meta,
406
+ )
407
+ emit_result(result, output)
408
+
409
+
410
+ @app.command()
411
+ def station(
412
+ region: str = typer.Argument(
413
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
414
+ ),
415
+ datetime_str: str = typer.Argument(
416
+ ..., help="YYYYMMDDHH (精确小时) 或 YYYYMMDD (自动取最新小时)"
417
+ ),
418
+ # --- optional overrides ---
419
+ var: str = typer.Option(
420
+ "AQI",
421
+ "--var",
422
+ "-v",
423
+ help="变量名: AQI, PM2.5, PM10, NO2, O3, SO2, CO, O3_8h, *_24h",
424
+ ),
425
+ level: str | None = typer.Option(
426
+ None,
427
+ "--level",
428
+ "-l",
429
+ help="强制指定级别: country / key_region / province / city / county",
430
+ ),
431
+ data_dir: str | None = typer.Option(
432
+ None,
433
+ "--data-dir",
434
+ "-d",
435
+ help="数据目录 (覆盖配置默认值)",
436
+ ),
437
+ output: str | None = typer.Option(
438
+ None,
439
+ "--output",
440
+ "-o",
441
+ help="输出文件路径",
442
+ ),
443
+ dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
444
+ basemap: str | None = typer.Option(
445
+ None,
446
+ "--basemap",
447
+ "-b",
448
+ help="底图控制: satellite / none",
449
+ ),
450
+ title: str | None = typer.Option(None, "--title", help="自定义标题"),
451
+ ) -> None:
452
+ """绘制国控站点散点图。
453
+
454
+ 示例:
455
+ rsplot station 安徽 20260414 (自动取当天最新小时)
456
+ rsplot station 安徽 2026041415 (精确到15时)
457
+ rsplot station 中国 20260414 --var PM2.5_24h
458
+ rsplot station YRD 2026041415 --var NO2 -o logs/yrd_no2.png
459
+ """
460
+ from rsplot.config import STATION_VAR_META, get_exceedance_threshold
461
+ from rsplot.geo.boundaries import resolve_region
462
+ from rsplot.plotting.station import plot_station
463
+ from rsplot.plotting.styles import setup_fonts
464
+ from rsplot.readers.guokong import mask_stations_to_region, read_stations
465
+
466
+ # --- 1. Load config ---
467
+ cfg = AppConfig.load()
468
+ setup_fonts(cfg.font_dir)
469
+
470
+ # --- 2. Validate variable ---
471
+ if var not in STATION_VAR_META:
472
+ available = ", ".join(STATION_VAR_META)
473
+ console.print(f"[red]未知变量 '{var}'[/red]。可用: {available}")
474
+ raise typer.Exit(1)
475
+
476
+ # --- 3. Resolve region ---
477
+ console.print(f"[bold]解析区域:[/bold] {region}")
478
+ info = resolve_region(region, cfg, level_override=level)
479
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
480
+
481
+ # --- 4. Read station data ---
482
+ use_data_dir = (
483
+ data_dir if data_dir is not None else cfg.get_data_dir("guokong")
484
+ )
485
+ console.print(f"\n[bold]读取站点数据:[/bold] {use_data_dir}")
486
+ console.print(f" 时间: {datetime_str}, 变量: {var}")
487
+
488
+ # Read with buffer for edge stations
489
+ buf = 0.5
490
+ read_extent = (
491
+ info.extent[0] - buf,
492
+ info.extent[1] + buf,
493
+ info.extent[2] - buf,
494
+ info.extent[3] + buf,
495
+ )
496
+ data, resolved_dt = read_stations(
497
+ use_data_dir, datetime_str, var, extent=read_extent
498
+ )
499
+ data = mask_stations_to_region(data, info.geometry)
500
+ if resolved_dt != datetime_str:
501
+ console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
502
+ console.print(
503
+ f" → [green]{data.n_valid}[/green] 有效站点 "
504
+ f"(共 {data.n_stations} 站)"
505
+ )
506
+
507
+ # --- 5. Exceedance threshold ---
508
+ threshold = get_exceedance_threshold(var)
509
+ if threshold is not None:
510
+ n_exceed = int(np.sum(data.values[np.isfinite(data.values)] > threshold))
511
+ console.print(
512
+ f" → 超标阈值: {threshold}, "
513
+ f"超标站点: [red]{n_exceed}[/red]"
514
+ )
515
+
516
+ # --- 6. Plot ---
517
+ use_dpi = dpi if dpi is not None else cfg.dpi
518
+
519
+ if basemap is not None:
520
+ use_basemap: bool | None = basemap.lower() == "satellite"
521
+ else:
522
+ use_basemap = None
523
+ _warn_missing_tianditu_key(
524
+ basemap=use_basemap,
525
+ default_basemap=params.basemap,
526
+ tianditu_key=cfg.tianditu_key,
527
+ )
528
+
529
+ unit = STATION_VAR_META[var][0]
530
+ colorbar_label = f"{var} ({unit})" if unit else var
531
+
532
+ if output is None:
533
+ output = f"/tmp/rsplot_station_{info.name}_{var}_{resolved_dt}.png"
534
+
535
+ console.print("\n[bold]绘图中...[/bold]")
536
+ plot_station(
537
+ data,
538
+ info,
539
+ threshold=threshold,
540
+ dpi=use_dpi,
541
+ basemap=use_basemap,
542
+ tianditu_key=cfg.tianditu_key,
543
+ title=title,
544
+ output=output,
545
+ colorbar_label=colorbar_label,
546
+ )
547
+
548
+ console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
549
+
550
+ # --- 7. Emit structured result for downstream agents ---
551
+ from rsplot.geo.boundaries import _load_city
552
+ from rsplot.results import build_station_result, emit_result
553
+
554
+ result = build_station_result(
555
+ region=info,
556
+ data=data,
557
+ datetime_str=resolved_dt,
558
+ threshold=threshold,
559
+ unit=unit,
560
+ cities_gdf=_load_city(cfg.geojson_dir),
561
+ output=output,
562
+ )
563
+ emit_result(result, output)
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # Station variable defaults that pair naturally with each satellite product.
568
+ # Used when the user asks for an overlay but doesn't specify --var explicitly.
569
+ # ---------------------------------------------------------------------------
570
+ _PRODUCT_STATION_PAIR: dict[str, str] = {
571
+ "no2": "NO2",
572
+ "o3": "O3",
573
+ }
574
+
575
+
576
+ @app.command()
577
+ def overlay(
578
+ region: str = typer.Argument(
579
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
580
+ ),
581
+ datetime_str: str = typer.Argument(
582
+ ...,
583
+ help="YYYYMMDDHH (精确小时) 或 YYYYMMDD (自动取最新小时);"
584
+ "日期部分用于卫星,小时部分用于站点",
585
+ ),
586
+ # --- satellite product ---
587
+ product: str = typer.Option(
588
+ "o3",
589
+ "--product",
590
+ "-p",
591
+ help="卫星产品类型: no2 / o3",
592
+ ),
593
+ # --- station variable ---
594
+ var: str | None = typer.Option(
595
+ None,
596
+ "--var",
597
+ "-v",
598
+ help="站点变量名,省略时按 product 自动选同名变量 (NO2 / O3)",
599
+ ),
600
+ # --- overrides ---
601
+ level: str | None = typer.Option(
602
+ None,
603
+ "--level",
604
+ "-l",
605
+ help="强制指定级别: country / key_region / province / city / county",
606
+ ),
607
+ raster_dir: str | None = typer.Option(
608
+ None,
609
+ "--raster-dir",
610
+ help="卫星数据目录 (覆盖配置默认值)",
611
+ ),
612
+ station_dir: str | None = typer.Option(
613
+ None,
614
+ "--station-dir",
615
+ help="国控站点数据目录 (覆盖配置默认值)",
616
+ ),
617
+ output: str | None = typer.Option(
618
+ None,
619
+ "--output",
620
+ "-o",
621
+ help="输出文件路径,不指定则弹窗显示。",
622
+ ),
623
+ dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
624
+ res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
625
+ basemap: str | None = typer.Option(
626
+ None,
627
+ "--basemap",
628
+ "-b",
629
+ help="底图控制: satellite / none (覆盖级别默认)",
630
+ ),
631
+ vmin: float | None = typer.Option(None, "--vmin", help="卫星 colorbar 最小值"),
632
+ vmax: float | None = typer.Option(None, "--vmax", help="卫星 colorbar 最大值"),
633
+ cmap: str | None = typer.Option(None, "--cmap", help="卫星 colormap 名称"),
634
+ smooth: float | None = typer.Option(
635
+ None, "--smooth", help="高斯平滑 sigma"
636
+ ),
637
+ qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
638
+ title: str | None = typer.Option(None, "--title", help="自定义标题"),
639
+ ) -> None:
640
+ """叠加绘制卫星栅格+国控站点图。
641
+
642
+ 两种数据同时输出到一张 PNG 和一个合并的 JSON sidecar。
643
+ 日期部分 (YYYYMMDD) 用于卫星,整串 (YYYYMMDDHH) 用于站点;
644
+ YYYYMMDD 会自动选当天最新小时的站点数据。
645
+
646
+ 示例:
647
+ rsplot overlay YRD 20260208 -p no2
648
+ rsplot overlay 安徽 2026041415 -p o3 -v O3_8h
649
+ rsplot overlay 中国 20260208 -p no2 -o logs/overlay/china_no2.png
650
+ """
651
+ from rsplot.config import STATION_VAR_META, get_exceedance_threshold
652
+ from rsplot.geo.boundaries import _load_city, resolve_region
653
+ from rsplot.geo.gridding import (
654
+ fill_nan_gaps,
655
+ grid_data,
656
+ mask_to_region,
657
+ smooth_grid,
658
+ )
659
+ from rsplot.plotting.overlay import plot_overlay
660
+ from rsplot.plotting.styles import setup_fonts
661
+ from rsplot.readers import auto_vrange, get_product_info
662
+ from rsplot.readers.guokong import mask_stations_to_region, read_stations
663
+ from rsplot.results import build_overlay_result, emit_result
664
+
665
+ # --- 1. Load config ---
666
+ cfg = AppConfig.load()
667
+ setup_fonts(cfg.font_dir)
668
+
669
+ # --- 2. Resolve station variable default from product ---
670
+ if var is None:
671
+ var = _PRODUCT_STATION_PAIR.get(product.lower(), "AQI")
672
+ if var not in STATION_VAR_META:
673
+ available = ", ".join(STATION_VAR_META)
674
+ console.print(f"[red]未知站点变量 '{var}'[/red]。可用: {available}")
675
+ raise typer.Exit(1)
676
+
677
+ # --- 3. Split datetime: first 8 chars = date for satellite ---
678
+ if not datetime_str.isdigit() or len(datetime_str) not in (8, 10):
679
+ console.print(
680
+ f"[red]datetime 参数 '{datetime_str}' 格式不正确[/red],"
681
+ f"请使用 YYYYMMDD 或 YYYYMMDDHH。"
682
+ )
683
+ raise typer.Exit(1)
684
+ date = datetime_str[:8]
685
+
686
+ # --- 4. Resolve region ---
687
+ console.print(f"[bold]解析区域:[/bold] {region}")
688
+ info = resolve_region(region, cfg, level_override=level)
689
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
690
+ console.print(
691
+ f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
692
+ f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
693
+ )
694
+
695
+ # --- 5. Merge params ---
696
+ params = info.params
697
+ use_res = res if res is not None else params.res
698
+ use_dpi = dpi if dpi is not None else cfg.dpi
699
+ prod_info = get_product_info(product)
700
+ use_cmap = cmap if cmap is not None else prod_info.cmap
701
+ use_qa = qa if qa is not None else cfg.qa_threshold
702
+ use_smooth = smooth if smooth is not None else params.smooth_sigma
703
+
704
+ if basemap is not None:
705
+ use_basemap: bool | None = basemap.lower() == "satellite"
706
+ else:
707
+ use_basemap = None
708
+ _warn_missing_tianditu_key(
709
+ basemap=use_basemap,
710
+ default_basemap=params.basemap,
711
+ tianditu_key=cfg.tianditu_key,
712
+ )
713
+
714
+ # --- 6. Read satellite swath ---
715
+ use_raster_dir = (
716
+ raster_dir if raster_dir is not None else cfg.get_data_dir(product)
717
+ )
718
+ reader = prod_info.reader_cls()
719
+ raster_label = prod_info.colorbar_label
720
+ buf = 1.0
721
+ read_extent = (
722
+ info.extent[0] - buf,
723
+ info.extent[1] + buf,
724
+ info.extent[2] - buf,
725
+ info.extent[3] + buf,
726
+ )
727
+ console.print(f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})")
728
+ console.print(f" 日期: {date}, QA > {use_qa}")
729
+ swath = reader.read(use_raster_dir, date, read_extent, qa_threshold=use_qa)
730
+ console.print(
731
+ f" → [green]{swath.n_pixels}[/green] 有效像元 "
732
+ f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
733
+ )
734
+
735
+ # --- 7. Grid + mask + smooth ---
736
+ console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
737
+ LON, LAT, grid_raw = grid_data(
738
+ swath.lon, swath.lat, swath.values, read_extent, use_res
739
+ )
740
+ grid_filled = fill_nan_gaps(LON, LAT, grid_raw)
741
+ grid_masked = mask_to_region(LON, LAT, grid_filled, info.geometry)
742
+ coverage = float(np.isfinite(grid_masked).mean()) * 100
743
+ console.print(f" → 网格 {grid_masked.shape}, 覆盖率 {coverage:.1f}%")
744
+
745
+ if use_smooth is not None:
746
+ grid_masked = smooth_grid(
747
+ grid_masked, info.geometry, LON, LAT, sigma=use_smooth
748
+ )
749
+
750
+ # --- 8. Raster vmin/vmax ---
751
+ if vmin is not None and vmax is not None:
752
+ use_vmin, use_vmax = vmin, vmax
753
+ elif prod_info.vmin is not None and prod_info.vmax is not None:
754
+ use_vmin = vmin if vmin is not None else prod_info.vmin
755
+ use_vmax = vmax if vmax is not None else prod_info.vmax
756
+ else:
757
+ auto_min, auto_max = auto_vrange(grid_masked, prod_info)
758
+ use_vmin = vmin if vmin is not None else auto_min
759
+ use_vmax = vmax if vmax is not None else auto_max
760
+ console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
761
+
762
+ # --- 9. Read station data ---
763
+ use_station_dir = (
764
+ station_dir if station_dir is not None else cfg.get_data_dir("guokong")
765
+ )
766
+ console.print(f"\n[bold]读取站点数据:[/bold] {use_station_dir}")
767
+ console.print(f" 时间: {datetime_str}, 变量: {var}")
768
+ station_buf = 0.5
769
+ station_extent = (
770
+ info.extent[0] - station_buf,
771
+ info.extent[1] + station_buf,
772
+ info.extent[2] - station_buf,
773
+ info.extent[3] + station_buf,
774
+ )
775
+ data, resolved_dt = read_stations(
776
+ use_station_dir, datetime_str, var, extent=station_extent
777
+ )
778
+ data = mask_stations_to_region(data, info.geometry)
779
+ if resolved_dt != datetime_str:
780
+ console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
781
+ console.print(
782
+ f" → [green]{data.n_valid}[/green] 有效站点 "
783
+ f"(共 {data.n_stations} 站)"
784
+ )
785
+
786
+ # --- 10. Exceedance threshold ---
787
+ threshold = get_exceedance_threshold(var)
788
+ if threshold is not None:
789
+ n_exceed = int(np.sum(data.values[np.isfinite(data.values)] > threshold))
790
+ console.print(
791
+ f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
792
+ )
793
+
794
+ # --- 11. Plot ---
795
+ if output is None:
796
+ output = (
797
+ f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
798
+ )
799
+
800
+ unit = STATION_VAR_META[var][0]
801
+ station_label = f"{var} ({unit})" if unit else var
802
+
803
+ console.print("\n[bold]绘图中...[/bold]")
804
+ plot_overlay(
805
+ LON,
806
+ LAT,
807
+ grid_masked,
808
+ data,
809
+ info,
810
+ raster_cmap=use_cmap,
811
+ raster_vmin=use_vmin,
812
+ raster_vmax=use_vmax,
813
+ raster_label=raster_label,
814
+ station_threshold=threshold,
815
+ station_label=station_label,
816
+ dpi=use_dpi,
817
+ basemap=use_basemap,
818
+ tianditu_key=cfg.tianditu_key,
819
+ title=title,
820
+ output=output,
821
+ )
822
+
823
+ console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
824
+
825
+ # --- 12. Emit combined result ---
826
+ result = build_overlay_result(
827
+ region=info,
828
+ product=product,
829
+ date=date,
830
+ prod_info=prod_info,
831
+ LON=LON,
832
+ LAT=LAT,
833
+ grid=grid_masked,
834
+ swath_meta={
835
+ "n_pixels": int(swath.n_pixels),
836
+ "n_files": int(swath.n_files),
837
+ "n_broken": int(swath.n_broken),
838
+ "qa_threshold": use_qa,
839
+ "resolution_deg": use_res,
840
+ },
841
+ raster_vmin=use_vmin,
842
+ raster_vmax=use_vmax,
843
+ data=data,
844
+ datetime_str=resolved_dt,
845
+ threshold=threshold,
846
+ unit=unit,
847
+ cities_gdf=_load_city(cfg.geojson_dir),
848
+ output=output,
849
+ )
850
+ emit_result(result, output)
851
+
852
+
853
+ @app.command()
854
+ def fnr(
855
+ region: str = typer.Argument(
856
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
857
+ ),
858
+ date_arg: str = typer.Argument(
859
+ ...,
860
+ help="单日 (YYYYMMDD,默认向前 --days 天) 或范围 "
861
+ "(YYYYMMDD-YYYYMMDD,闭区间)",
862
+ ),
863
+ # --- window ---
864
+ days: int = typer.Option(
865
+ 7,
866
+ "--days",
867
+ help="时间窗口天数(仅当 date_arg 为单日时生效,默认 7)",
868
+ ),
869
+ n_min: int | None = typer.Option(
870
+ None,
871
+ "--n-min",
872
+ help="每像元最少有效天数,默认 max(3, 窗口天数 // 2)",
873
+ ),
874
+ no2_min: float = typer.Option(
875
+ 0.5,
876
+ "--no2-min",
877
+ help="最小平均 NO2 柱浓度阈值 (x10^15 molec/cm2);"
878
+ "低于此值的像元 FNR 不可信,输出为 NaN",
879
+ ),
880
+ # --- overrides ---
881
+ level: str | None = typer.Option(
882
+ None,
883
+ "--level",
884
+ "-l",
885
+ help="强制指定级别: country / key_region / province / city / county",
886
+ ),
887
+ raster_dir_no2: str | None = typer.Option(
888
+ None,
889
+ "--no2-dir",
890
+ help="NO2 数据目录 (覆盖配置默认值)",
891
+ ),
892
+ raster_dir_hcho: str | None = typer.Option(
893
+ None,
894
+ "--hcho-dir",
895
+ help="HCHO 数据目录 (覆盖配置默认值)",
896
+ ),
897
+ output: str | None = typer.Option(
898
+ None,
899
+ "--output",
900
+ "-o",
901
+ help="输出文件路径",
902
+ ),
903
+ dpi: int | None = typer.Option(300, "--dpi", help="输出 DPI"),
904
+ res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
905
+ basemap: str | None = typer.Option(
906
+ None,
907
+ "--basemap",
908
+ "-b",
909
+ help="底图控制: satellite / none",
910
+ ),
911
+ smooth: float | None = typer.Option(
912
+ None, "--smooth", help="高斯平滑 sigma"
913
+ ),
914
+ qa: float | None = typer.Option(
915
+ 0.5,
916
+ "--qa",
917
+ help="QA 阈值 (0-1),同时应用于 NO2 和 HCHO(OFFL L2 swath 推荐 ≥0.5)",
918
+ ),
919
+ title: str | None = typer.Option(None, "--title", help="自定义标题"),
920
+ ) -> None:
921
+ """绘制 FNR (HCHO / NO2) 区域控制 regime 图。
922
+
923
+ 时间窗口平均后再相除(避免日尺度 HCHO 覆盖率太低)。
924
+ 分级依据 Duncan 2010: FNR<1 VOC-limited, 1~2 过渡, >2 NOx-limited.
925
+
926
+ 示例:
927
+ rsplot fnr 合肥 20260114 (7日窗口)
928
+ rsplot fnr 安徽 20260131 --days 30 (30日窗口)
929
+ rsplot fnr 长三角 20260101-20260130 (显式范围)
930
+ """
931
+ from rsplot.fnr import compute_fnr, parse_date_window
932
+ from rsplot.geo.boundaries import resolve_region
933
+ from rsplot.plotting.fnr import plot_fnr
934
+ from rsplot.plotting.styles import setup_fonts
935
+ from rsplot.results import build_fnr_result, emit_result
936
+
937
+ # --- 1. Load config ---
938
+ cfg = AppConfig.load()
939
+ setup_fonts(cfg.font_dir)
940
+
941
+ # --- 2. Parse date window ---
942
+ try:
943
+ dates = parse_date_window(date_arg, default_days=days)
944
+ except ValueError as e:
945
+ console.print(f"[red]{e}[/red]")
946
+ raise typer.Exit(1) from e
947
+
948
+ window_n = len(dates)
949
+ use_n_min = n_min if n_min is not None else max(3, window_n // 2)
950
+ if use_n_min > window_n:
951
+ console.print(
952
+ f"[red]--n-min ({use_n_min}) 大于窗口天数 ({window_n})[/red]"
953
+ )
954
+ raise typer.Exit(1)
955
+
956
+ console.print(
957
+ f"[bold]时间窗口:[/bold] {dates[0]} ~ {dates[-1]} "
958
+ f"({window_n} 天,每像元至少 {use_n_min} 天有效)"
959
+ )
960
+
961
+ # --- 3. Resolve region ---
962
+ console.print(f"[bold]解析区域:[/bold] {region}")
963
+ info = resolve_region(region, cfg, level_override=level)
964
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
965
+
966
+ # --- 4. Merge params ---
967
+ params = info.params
968
+ use_res = res if res is not None else params.res
969
+ use_dpi = dpi if dpi is not None else cfg.dpi
970
+ use_qa = qa if qa is not None else cfg.qa_threshold
971
+ use_smooth = smooth if smooth is not None else params.smooth_sigma
972
+
973
+ if basemap is not None:
974
+ use_basemap: bool | None = basemap.lower() == "satellite"
975
+ else:
976
+ use_basemap = None
977
+ _warn_missing_tianditu_key(
978
+ basemap=use_basemap,
979
+ default_basemap=params.basemap,
980
+ tianditu_key=cfg.tianditu_key,
981
+ )
982
+
983
+ # --- 5. Resolve data dirs ---
984
+ use_no2_dir = (
985
+ raster_dir_no2 if raster_dir_no2 is not None else cfg.get_data_dir("no2")
986
+ )
987
+ use_hcho_dir = (
988
+ raster_dir_hcho
989
+ if raster_dir_hcho is not None
990
+ else cfg.get_data_dir("hcho")
991
+ )
992
+ console.print(f"\n[bold]读取数据:[/bold]")
993
+ console.print(f" NO2 : {use_no2_dir}")
994
+ console.print(f" HCHO: {use_hcho_dir}")
995
+
996
+ # --- 6. Compute FNR ---
997
+ fnr_out = compute_fnr(
998
+ info,
999
+ dates,
1000
+ res=use_res,
1001
+ qa_threshold=use_qa,
1002
+ n_min=use_n_min,
1003
+ raster_dir_no2=use_no2_dir,
1004
+ raster_dir_hcho=use_hcho_dir,
1005
+ no2_min_column=no2_min,
1006
+ smooth_sigma=use_smooth,
1007
+ console=console,
1008
+ )
1009
+
1010
+ n_fail_no2 = len(fnr_out.dates_failed_no2)
1011
+ n_fail_hcho = len(fnr_out.dates_failed_hcho)
1012
+ n_found_no2 = len(fnr_out.dates_found_no2)
1013
+ n_found_hcho = len(fnr_out.dates_found_hcho)
1014
+ console.print(
1015
+ f" → NO2 有效天数 {n_found_no2}/{window_n} "
1016
+ f"(失败 {n_fail_no2})"
1017
+ )
1018
+ console.print(
1019
+ f" → HCHO 有效天数 {n_found_hcho}/{window_n} "
1020
+ f"(失败 {n_fail_hcho})"
1021
+ )
1022
+ if use_n_min > min(n_found_no2, n_found_hcho):
1023
+ console.print(
1024
+ f" → [yellow]注意:[/yellow] n_min={use_n_min} 大于 NO2/HCHO "
1025
+ f"共同可用天数上限 {min(n_found_no2, n_found_hcho)},"
1026
+ "本次不可能产生有效 FNR 像元。"
1027
+ )
1028
+
1029
+ finite = fnr_out.fnr[np.isfinite(fnr_out.fnr)]
1030
+ if len(finite) == 0:
1031
+ _print_failed_dates("NO2", fnr_out.dates_failed_no2)
1032
+ _print_failed_dates("HCHO", fnr_out.dates_failed_hcho)
1033
+ console.print(
1034
+ "[red]FNR 全区无有效像元,可能原因:窗口内 NO2/HCHO 都覆盖不足,"
1035
+ "或 --n-min 太严。可放宽 --n-min / 扩大窗口。[/red]"
1036
+ )
1037
+ raise typer.Exit(1)
1038
+
1039
+ coverage_pct = 100 * len(finite) / fnr_out.fnr.size
1040
+ console.print(
1041
+ f" → FNR 覆盖率 {coverage_pct:.1f}%, "
1042
+ f"中位数 {float(np.median(finite)):.2f}"
1043
+ )
1044
+
1045
+ from rsplot.fnr import regime_percentages
1046
+ pct = regime_percentages(fnr_out.fnr)
1047
+ console.print(
1048
+ f" → Regime: [red]VOC-limited {pct['voc_limited']}%[/red] | "
1049
+ f"[yellow]transition {pct['transition']}%[/yellow] | "
1050
+ f"[blue]NOx-limited {pct['nox_limited']}%[/blue]"
1051
+ )
1052
+
1053
+ # --- 7. Plot ---
1054
+ if output is None:
1055
+ output = (
1056
+ f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
1057
+ )
1058
+
1059
+ subtitle = (
1060
+ f"{dates[0]}~{dates[-1]} · N_days={window_n} "
1061
+ f"(n_min={use_n_min}) · NO2≥{no2_min:g}×10¹⁵ molec/cm²"
1062
+ )
1063
+
1064
+ console.print("\n[bold]绘图中...[/bold]")
1065
+ plot_fnr(
1066
+ fnr_out.LON,
1067
+ fnr_out.LAT,
1068
+ fnr_out.fnr,
1069
+ info,
1070
+ dpi=use_dpi,
1071
+ basemap=use_basemap,
1072
+ tianditu_key=cfg.tianditu_key,
1073
+ title=title,
1074
+ output=output,
1075
+ subtitle=subtitle,
1076
+ )
1077
+ console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
1078
+
1079
+ # --- 8. JSON sidecar ---
1080
+ result = build_fnr_result(
1081
+ region=info,
1082
+ fnr_out=fnr_out,
1083
+ res=use_res,
1084
+ output=output,
1085
+ )
1086
+ emit_result(result, output)
1087
+
1088
+
1089
+ def _print_failed_dates(
1090
+ label: str, failed: dict[str, str], max_items: int = 8
1091
+ ) -> None:
1092
+ """Print a compact failed-date summary for window commands."""
1093
+ if not failed:
1094
+ return
1095
+ console.print(f" → [yellow]{label} 失败日期示例:[/yellow]")
1096
+ items = list(failed.items())
1097
+ for d, msg in items[:max_items]:
1098
+ console.print(f" - {d}: {msg}")
1099
+ if len(items) > max_items:
1100
+ console.print(f" ... 另有 {len(items) - max_items} 天")