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/__init__.py +3 -0
- rsplot/__main__.py +6 -0
- rsplot/cli.py +1100 -0
- rsplot/config.py +290 -0
- rsplot/fnr.py +384 -0
- rsplot/geo/__init__.py +0 -0
- rsplot/geo/boundaries.py +281 -0
- rsplot/geo/gridding.py +120 -0
- rsplot/plotting/__init__.py +1 -0
- rsplot/plotting/colormaps.py +67 -0
- rsplot/plotting/fnr.py +200 -0
- rsplot/plotting/overlay.py +241 -0
- rsplot/plotting/raster.py +222 -0
- rsplot/plotting/station.py +225 -0
- rsplot/plotting/styles.py +35 -0
- rsplot/readers/__init__.py +96 -0
- rsplot/readers/base.py +96 -0
- rsplot/readers/guokong.py +143 -0
- rsplot/readers/tropomi_hcho.py +126 -0
- rsplot/readers/tropomi_no2.py +102 -0
- rsplot/readers/tropomi_o3.py +104 -0
- rsplot/results.py +553 -0
- rsplot/tiles/__init__.py +1 -0
- rsplot/tiles/tianditu.py +32 -0
- rsplot-0.1.0.dist-info/METADATA +252 -0
- rsplot-0.1.0.dist-info/RECORD +29 -0
- rsplot-0.1.0.dist-info/WHEEL +4 -0
- rsplot-0.1.0.dist-info/entry_points.txt +2 -0
- rsplot-0.1.0.dist-info/licenses/LICENSE +201 -0
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} 天")
|