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 +48 -10
- rsplot/commands/common.py +64 -1
- rsplot/commands/forecast.py +24 -5
- rsplot/commands/overlay.py +172 -43
- rsplot/commands/raster.py +168 -44
- rsplot/commands/recent.py +29 -7
- rsplot/commands/reconstruct.py +27 -5
- rsplot/config.py +3 -0
- rsplot/geo/boundaries.py +105 -2
- rsplot/geo/footprints.py +202 -0
- rsplot/geo/processing.py +82 -0
- rsplot/plotting/map_frame.py +120 -4
- rsplot/plotting/overlay.py +5 -0
- rsplot/plotting/raster.py +5 -0
- rsplot/readers/__init__.py +38 -4
- rsplot/readers/base.py +16 -1
- rsplot/readers/gems.py +426 -0
- rsplot/recent.py +59 -16
- rsplot/result_builders/common.py +81 -0
- rsplot/result_builders/forecast.py +7 -0
- rsplot/result_builders/overlay.py +32 -2
- rsplot/result_builders/raster.py +32 -0
- rsplot/result_builders/reconstruct.py +7 -0
- rsplot/temporal.py +139 -16
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/METADATA +215 -1
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/RECORD +29 -27
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/WHEEL +0 -0
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/entry_points.txt +0 -0
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/licenses/LICENSE +0 -0
rsplot/commands/raster.py
CHANGED
|
@@ -10,6 +10,9 @@ import typer
|
|
|
10
10
|
|
|
11
11
|
from rsplot.commands.common import (
|
|
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,
|
|
@@ -60,11 +63,13 @@ def parse_raster_dates(
|
|
|
60
63
|
return dates, window_mode
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
@satellite_errors
|
|
63
67
|
def run_raster(
|
|
64
68
|
*,
|
|
65
69
|
region: str,
|
|
66
70
|
date_arg: str,
|
|
67
71
|
product: str,
|
|
72
|
+
sensor: str = "tropomi",
|
|
68
73
|
days: int | None,
|
|
69
74
|
n_min: int | None,
|
|
70
75
|
level: str | None,
|
|
@@ -83,20 +88,33 @@ def run_raster(
|
|
|
83
88
|
console: Console,
|
|
84
89
|
) -> None:
|
|
85
90
|
"""Execute raster orchestration without owning the Typer signature."""
|
|
86
|
-
from rsplot.geo.boundaries import
|
|
91
|
+
from rsplot.geo.boundaries import resolve_regions
|
|
87
92
|
from rsplot.geo.processing import (
|
|
88
93
|
buffered_extent,
|
|
94
|
+
prepare_display_grid,
|
|
89
95
|
prepare_existing_grid,
|
|
90
96
|
prepare_swath_grid,
|
|
91
97
|
)
|
|
92
98
|
from rsplot.plotting.raster import plot_raster
|
|
93
99
|
from rsplot.plotting.styles import setup_fonts
|
|
94
|
-
from rsplot.readers import auto_vrange
|
|
100
|
+
from rsplot.readers import auto_vrange
|
|
95
101
|
from rsplot.results import build_raster_result, emit_result
|
|
96
|
-
from rsplot.temporal import compute_window_mean
|
|
102
|
+
from rsplot.temporal import compute_window_mean, read_scan_mean
|
|
97
103
|
|
|
104
|
+
cfg = AppConfig.load()
|
|
105
|
+
prod_info, use_qa = resolve_satellite(product, sensor, qa, cfg, console)
|
|
106
|
+
sensor = prod_info.sensor
|
|
107
|
+
scan_mode = sensor == "gems" and len(date_arg) == 12
|
|
108
|
+
if scan_mode:
|
|
109
|
+
from rsplot.readers.gems import parse_gems_time
|
|
110
|
+
|
|
111
|
+
parse_gems_time(date_arg)
|
|
112
|
+
if days is not None or n_min is not None:
|
|
113
|
+
raise ValueError("GEMS 单次扫描不能同时使用 --days 或 --n-min。")
|
|
98
114
|
try:
|
|
99
|
-
dates, window_mode = parse_raster_dates(
|
|
115
|
+
dates, window_mode = parse_raster_dates(
|
|
116
|
+
date_arg[:8] if scan_mode else date_arg, days
|
|
117
|
+
)
|
|
100
118
|
except ValueError as e:
|
|
101
119
|
console.print(f"[red]{e}[/red]")
|
|
102
120
|
raise typer.Exit(1) from e
|
|
@@ -121,12 +139,18 @@ def run_raster(
|
|
|
121
139
|
)
|
|
122
140
|
raise typer.Exit(1)
|
|
123
141
|
|
|
124
|
-
cfg = AppConfig.load()
|
|
125
142
|
setup_fonts(cfg.font_dir)
|
|
126
143
|
|
|
127
144
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
128
|
-
info =
|
|
145
|
+
info = resolve_regions(region, cfg, level_override=level)
|
|
129
146
|
console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
|
|
147
|
+
if info.members:
|
|
148
|
+
console.print(
|
|
149
|
+
" → 成员: "
|
|
150
|
+
+ "、".join(
|
|
151
|
+
member.qualified_name or member.name for member in info.members
|
|
152
|
+
)
|
|
153
|
+
)
|
|
130
154
|
console.print(
|
|
131
155
|
f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
|
|
132
156
|
f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
|
|
@@ -134,7 +158,6 @@ def run_raster(
|
|
|
134
158
|
|
|
135
159
|
params = info.params
|
|
136
160
|
use_dpi = dpi if dpi is not None else cfg.dpi
|
|
137
|
-
prod_info = get_product_info(product)
|
|
138
161
|
product_defaults = prod_info.defaults_for_level(info.level)
|
|
139
162
|
use_res = (
|
|
140
163
|
res
|
|
@@ -144,7 +167,6 @@ def run_raster(
|
|
|
144
167
|
else params.res
|
|
145
168
|
)
|
|
146
169
|
use_cmap = cmap if cmap is not None else prod_info.cmap
|
|
147
|
-
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
148
170
|
use_smooth = (
|
|
149
171
|
smooth
|
|
150
172
|
if smooth is not None
|
|
@@ -175,10 +197,20 @@ def run_raster(
|
|
|
175
197
|
|
|
176
198
|
read_extent = buffered_extent(info.extent, 1.0)
|
|
177
199
|
use_data_dir = (
|
|
178
|
-
data_dir
|
|
200
|
+
data_dir
|
|
201
|
+
if data_dir is not None
|
|
202
|
+
else cfg.get_data_dir(prod_info.data_key or product)
|
|
179
203
|
)
|
|
180
204
|
reader = prod_info.reader_cls()
|
|
181
205
|
reader.cloud_fraction_max = cf
|
|
206
|
+
analysis_res = use_res
|
|
207
|
+
if sensor == "gems":
|
|
208
|
+
from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
|
|
209
|
+
|
|
210
|
+
analysis_res = GEMS_ANALYSIS_RESOLUTION
|
|
211
|
+
console.print(
|
|
212
|
+
f" → GEMS 分析网格 {analysis_res}°,显示网格 {use_res}°"
|
|
213
|
+
)
|
|
182
214
|
swath_meta: dict[str, Any]
|
|
183
215
|
window_meta: dict[str, Any] | None = None
|
|
184
216
|
scientific_valid_mask: np.ndarray | None = None
|
|
@@ -187,34 +219,61 @@ def run_raster(
|
|
|
187
219
|
console.print(
|
|
188
220
|
f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
|
|
189
221
|
)
|
|
190
|
-
console.print(f" 日期: {dates[0]}, QA > {use_qa}, CF <= {cf}")
|
|
191
|
-
swath = reader.read(
|
|
192
|
-
use_data_dir,
|
|
193
|
-
dates[0],
|
|
194
|
-
read_extent,
|
|
195
|
-
qa_threshold=use_qa,
|
|
196
|
-
)
|
|
197
222
|
console.print(
|
|
198
|
-
f"
|
|
199
|
-
f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
|
|
223
|
+
f" 时间: {date_arg}, {satellite_filter_label(sensor, use_qa, cf)}"
|
|
200
224
|
)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
if sensor == "gems":
|
|
226
|
+
batch = read_scan_mean(
|
|
227
|
+
reader=reader,
|
|
228
|
+
data_dir=use_data_dir,
|
|
229
|
+
request=date_arg,
|
|
230
|
+
extent=read_extent,
|
|
231
|
+
res=analysis_res,
|
|
232
|
+
)
|
|
233
|
+
prepared = prepare_existing_grid(
|
|
234
|
+
batch.LON,
|
|
235
|
+
batch.LAT,
|
|
236
|
+
batch.grid,
|
|
237
|
+
info.geometry,
|
|
238
|
+
fill_gaps=False,
|
|
239
|
+
)
|
|
240
|
+
swath_meta = {
|
|
241
|
+
"n_pixels": batch.n_pixels,
|
|
242
|
+
"n_files": batch.n_files,
|
|
243
|
+
"n_broken": batch.n_broken,
|
|
244
|
+
"resolution_deg": use_res,
|
|
245
|
+
"cloud_fraction_max": cf,
|
|
246
|
+
**batch.metadata,
|
|
247
|
+
}
|
|
248
|
+
scientific_valid_mask = np.isfinite(prepared.values)
|
|
249
|
+
else:
|
|
250
|
+
swath = reader.read(
|
|
251
|
+
use_data_dir,
|
|
252
|
+
dates[0],
|
|
253
|
+
read_extent,
|
|
254
|
+
qa_threshold=use_qa,
|
|
255
|
+
)
|
|
256
|
+
console.print(
|
|
257
|
+
f" → [green]{swath.n_pixels}[/green] 有效像元 "
|
|
258
|
+
f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
|
|
259
|
+
)
|
|
260
|
+
console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
|
|
261
|
+
prepared = prepare_swath_grid(
|
|
262
|
+
swath.lon,
|
|
263
|
+
swath.lat,
|
|
264
|
+
swath.values,
|
|
265
|
+
read_extent,
|
|
266
|
+
use_res,
|
|
267
|
+
info.geometry,
|
|
268
|
+
)
|
|
269
|
+
swath_meta = {
|
|
270
|
+
"n_pixels": int(swath.n_pixels),
|
|
271
|
+
"n_files": int(swath.n_files),
|
|
272
|
+
"n_broken": int(swath.n_broken),
|
|
273
|
+
"qa_threshold": use_qa,
|
|
274
|
+
"cloud_fraction_max": cf,
|
|
275
|
+
"resolution_deg": use_res,
|
|
276
|
+
}
|
|
218
277
|
else:
|
|
219
278
|
assert use_n_min is not None
|
|
220
279
|
console.print(
|
|
@@ -224,13 +283,13 @@ def run_raster(
|
|
|
224
283
|
console.print(
|
|
225
284
|
f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
|
|
226
285
|
)
|
|
227
|
-
console.print(f"
|
|
286
|
+
console.print(f" {satellite_filter_label(sensor, use_qa, cf)}")
|
|
228
287
|
window = compute_window_mean(
|
|
229
288
|
reader=reader,
|
|
230
289
|
data_dir=use_data_dir,
|
|
231
290
|
dates=dates,
|
|
232
291
|
extent=read_extent,
|
|
233
|
-
res=
|
|
292
|
+
res=analysis_res,
|
|
234
293
|
qa_threshold=use_qa,
|
|
235
294
|
label=product.upper(),
|
|
236
295
|
console=console,
|
|
@@ -287,14 +346,54 @@ def run_raster(
|
|
|
287
346
|
"coverage_days": coverage_days,
|
|
288
347
|
}
|
|
289
348
|
|
|
290
|
-
|
|
349
|
+
if sensor == "gems":
|
|
350
|
+
swath_meta.pop("qa_threshold", None)
|
|
351
|
+
window_meta["aggregation"] = "equal_day_mean_of_equal_scan_means"
|
|
352
|
+
window_meta["daily"] = window.daily_metadata
|
|
353
|
+
|
|
354
|
+
display_source = prepared.unmasked_values
|
|
355
|
+
statistics_grid = prepared.values.copy() if sensor == "gems" else None
|
|
356
|
+
display_lon, display_lat = prepared.lon, prepared.lat
|
|
357
|
+
display_support = None
|
|
358
|
+
if sensor == "gems":
|
|
359
|
+
from rsplot.geo.footprints import prepare_footprint_display
|
|
360
|
+
|
|
361
|
+
display = prepare_footprint_display(
|
|
362
|
+
prepared.lon,
|
|
363
|
+
prepared.lat,
|
|
364
|
+
display_source,
|
|
365
|
+
info.geometry,
|
|
366
|
+
analysis_res=analysis_res,
|
|
367
|
+
display_res=use_res,
|
|
368
|
+
smooth_sigma=use_smooth,
|
|
369
|
+
)
|
|
370
|
+
display_lon, display_lat = display.lon, display.lat
|
|
371
|
+
display_grid, display_support = display.values, display.support
|
|
372
|
+
swath_meta["resolution_deg"] = analysis_res
|
|
373
|
+
swath_meta["analysis_resolution_deg"] = analysis_res
|
|
374
|
+
swath_meta["display_resolution_deg"] = use_res
|
|
375
|
+
swath_meta["display_support"] = "fixed_analysis_valid_cells"
|
|
376
|
+
else:
|
|
377
|
+
display_grid = prepare_display_grid(
|
|
378
|
+
prepared.lon,
|
|
379
|
+
prepared.lat,
|
|
380
|
+
display_source,
|
|
381
|
+
info.geometry,
|
|
382
|
+
smooth_sigma=use_smooth,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if not np.isfinite(display_grid).any():
|
|
291
386
|
console.print(
|
|
292
387
|
"[red]窗口均值全区无有效像元,可能原因:窗口内覆盖不足,"
|
|
293
388
|
"或 --n-min 太严。可放宽 --n-min / 扩大窗口。[/red]"
|
|
294
389
|
)
|
|
295
390
|
raise typer.Exit(1)
|
|
296
391
|
|
|
297
|
-
if
|
|
392
|
+
if (
|
|
393
|
+
sensor != "gems"
|
|
394
|
+
and use_smooth is not None
|
|
395
|
+
and np.isfinite(prepared.values).any()
|
|
396
|
+
):
|
|
298
397
|
console.print(f" → 高斯平滑 sigma={use_smooth}")
|
|
299
398
|
prepared = prepare_existing_grid(
|
|
300
399
|
prepared.lon,
|
|
@@ -329,7 +428,12 @@ def run_raster(
|
|
|
329
428
|
use_vmin = vmin if vmin is not None else prod_info.vmin
|
|
330
429
|
use_vmax = vmax if vmax is not None else prod_info.vmax
|
|
331
430
|
else:
|
|
332
|
-
auto_min, auto_max = auto_vrange(
|
|
431
|
+
auto_min, auto_max = auto_vrange(
|
|
432
|
+
prepared.values
|
|
433
|
+
if np.isfinite(prepared.values).any()
|
|
434
|
+
else display_grid,
|
|
435
|
+
prod_info,
|
|
436
|
+
)
|
|
333
437
|
use_vmin = vmin if vmin is not None else auto_min
|
|
334
438
|
use_vmax = vmax if vmax is not None else auto_max
|
|
335
439
|
try:
|
|
@@ -339,11 +443,25 @@ def run_raster(
|
|
|
339
443
|
raise typer.Exit(1) from e
|
|
340
444
|
console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
|
|
341
445
|
|
|
446
|
+
if output is None and sensor == "gems":
|
|
447
|
+
suffix = f"{dates[0]}_{dates[-1]}" if window_mode else date_arg
|
|
448
|
+
output = f"/tmp/rsplot_gems_{product}_{info.name}_{suffix}.png"
|
|
342
449
|
if output is None:
|
|
343
450
|
if window_mode:
|
|
344
451
|
output = f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
345
452
|
else:
|
|
346
453
|
output = f"/tmp/rsplot_{info.name}_{dates[0]}.png"
|
|
454
|
+
if title is None and sensor == "gems":
|
|
455
|
+
period = (
|
|
456
|
+
f"{dates[0]}~{dates[-1]} mean"
|
|
457
|
+
if window_mode
|
|
458
|
+
else (
|
|
459
|
+
f"{date_arg} CST scan"
|
|
460
|
+
if scan_mode
|
|
461
|
+
else f"{date_arg} daily mean (CST)"
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
title = f"GEMS {product.upper()} - {info.name} ({period})"
|
|
347
465
|
if title is None and window_mode:
|
|
348
466
|
title = (
|
|
349
467
|
f"TROPOMI {product.upper()} - {info.name} "
|
|
@@ -352,9 +470,9 @@ def run_raster(
|
|
|
352
470
|
|
|
353
471
|
console.print("\n[bold]绘图中...[/bold]")
|
|
354
472
|
plot_raster(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
473
|
+
display_lon,
|
|
474
|
+
display_lat,
|
|
475
|
+
display_grid,
|
|
358
476
|
info,
|
|
359
477
|
dpi=use_dpi,
|
|
360
478
|
cmap=use_cmap,
|
|
@@ -365,6 +483,7 @@ def run_raster(
|
|
|
365
483
|
hcho_cmap_file=cfg.hcho_cmap_file,
|
|
366
484
|
title=title,
|
|
367
485
|
output=output,
|
|
486
|
+
**({"coverage_geometry": display_support} if sensor == "gems" else {}),
|
|
368
487
|
colorbar_label=prod_info.colorbar_label,
|
|
369
488
|
)
|
|
370
489
|
console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
|
|
@@ -376,11 +495,16 @@ def run_raster(
|
|
|
376
495
|
prod_info=prod_info,
|
|
377
496
|
LON=prepared.lon,
|
|
378
497
|
LAT=prepared.lat,
|
|
379
|
-
grid=
|
|
498
|
+
grid=statistics_grid
|
|
499
|
+
if statistics_grid is not None
|
|
500
|
+
else prepared.values,
|
|
380
501
|
swath_meta=swath_meta,
|
|
381
502
|
vmin=use_vmin,
|
|
382
503
|
vmax=use_vmax,
|
|
383
504
|
output=output,
|
|
384
505
|
window=window_meta,
|
|
506
|
+
display_grid=display_grid,
|
|
507
|
+
display_lon=display_lon,
|
|
508
|
+
display_lat=display_lat,
|
|
385
509
|
)
|
|
386
510
|
emit_result(result, output)
|
rsplot/commands/recent.py
CHANGED
|
@@ -7,6 +7,9 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from rsplot.commands.common import (
|
|
10
|
+
resolve_satellite,
|
|
11
|
+
satellite_errors,
|
|
12
|
+
satellite_filter_label,
|
|
10
13
|
validate_positive,
|
|
11
14
|
validate_unit_interval,
|
|
12
15
|
)
|
|
@@ -16,6 +19,7 @@ if TYPE_CHECKING:
|
|
|
16
19
|
from rich.console import Console
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
@satellite_errors
|
|
19
23
|
def run_recent(
|
|
20
24
|
*,
|
|
21
25
|
region: str,
|
|
@@ -23,6 +27,7 @@ def run_recent(
|
|
|
23
27
|
days: int,
|
|
24
28
|
source: str,
|
|
25
29
|
product: str,
|
|
30
|
+
sensor: str = "tropomi",
|
|
26
31
|
var: str,
|
|
27
32
|
level: str | None,
|
|
28
33
|
data_dir: str | None,
|
|
@@ -35,7 +40,6 @@ def run_recent(
|
|
|
35
40
|
"""Execute recent-day summaries without owning the Typer signature."""
|
|
36
41
|
from rsplot.config import STATION_VAR_META
|
|
37
42
|
from rsplot.geo.boundaries import resolve_region
|
|
38
|
-
from rsplot.readers import get_product_info
|
|
39
43
|
from rsplot.recent import (
|
|
40
44
|
build_recent_result,
|
|
41
45
|
parse_recent_dates,
|
|
@@ -56,6 +60,13 @@ def run_recent(
|
|
|
56
60
|
raise typer.Exit(1) from e
|
|
57
61
|
|
|
58
62
|
cfg = AppConfig.load()
|
|
63
|
+
if use_source == "raster":
|
|
64
|
+
prod_info, use_qa = resolve_satellite(
|
|
65
|
+
product, sensor, qa, cfg, console
|
|
66
|
+
)
|
|
67
|
+
sensor = prod_info.sensor
|
|
68
|
+
elif sensor.lower() != "tropomi":
|
|
69
|
+
raise ValueError("--sensor gems 仅用于 --source raster。")
|
|
59
70
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
60
71
|
info = resolve_region(region, cfg, level_override=level)
|
|
61
72
|
console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
|
|
@@ -66,7 +77,8 @@ def run_recent(
|
|
|
66
77
|
if output is None:
|
|
67
78
|
if use_source == "raster":
|
|
68
79
|
output = (
|
|
69
|
-
f"/tmp/rsplot_recent_{
|
|
80
|
+
f"/tmp/rsplot_recent_{'gems_' if sensor == 'gems' else ''}"
|
|
81
|
+
f"{info.name}_{product}_"
|
|
70
82
|
f"{dates[0]}_{dates[-1]}.json"
|
|
71
83
|
)
|
|
72
84
|
else:
|
|
@@ -76,7 +88,6 @@ def run_recent(
|
|
|
76
88
|
)
|
|
77
89
|
|
|
78
90
|
if use_source == "raster":
|
|
79
|
-
prod_info = get_product_info(product)
|
|
80
91
|
product_defaults = prod_info.defaults_for_level(info.level)
|
|
81
92
|
use_res = (
|
|
82
93
|
res
|
|
@@ -85,7 +96,6 @@ def run_recent(
|
|
|
85
96
|
if product_defaults.res is not None
|
|
86
97
|
else info.params.res
|
|
87
98
|
)
|
|
88
|
-
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
89
99
|
try:
|
|
90
100
|
validate_positive(use_res, "--res")
|
|
91
101
|
validate_unit_interval(use_qa, "--qa")
|
|
@@ -93,13 +103,19 @@ def run_recent(
|
|
|
93
103
|
except ValueError as e:
|
|
94
104
|
console.print(f"[red]{e}[/red]")
|
|
95
105
|
raise typer.Exit(1) from e
|
|
106
|
+
if sensor == "gems":
|
|
107
|
+
from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
|
|
108
|
+
|
|
109
|
+
use_res = GEMS_ANALYSIS_RESOLUTION
|
|
96
110
|
use_data_dir = (
|
|
97
|
-
data_dir
|
|
111
|
+
data_dir
|
|
112
|
+
if data_dir is not None
|
|
113
|
+
else cfg.get_data_dir(prod_info.data_key or product)
|
|
98
114
|
)
|
|
99
115
|
console.print(
|
|
100
116
|
f"\n[bold]统计栅格:[/bold] {use_data_dir} "
|
|
101
|
-
f"(产品: {product},
|
|
102
|
-
f"
|
|
117
|
+
f"(产品: {product}, {satellite_filter_label(sensor, use_qa, cf)}, "
|
|
118
|
+
f"res={use_res})"
|
|
103
119
|
)
|
|
104
120
|
daily = summarize_recent_raster(
|
|
105
121
|
region=info,
|
|
@@ -124,6 +140,8 @@ def run_recent(
|
|
|
124
140
|
"resolution_deg": use_res,
|
|
125
141
|
},
|
|
126
142
|
product=product,
|
|
143
|
+
sensor=sensor,
|
|
144
|
+
quantity=prod_info.quantity,
|
|
127
145
|
unit=prod_info.colorbar_label,
|
|
128
146
|
output=output,
|
|
129
147
|
)
|
|
@@ -132,6 +150,10 @@ def run_recent(
|
|
|
132
150
|
available = ", ".join(STATION_VAR_META)
|
|
133
151
|
console.print(f"[red]未知变量 '{var}'[/red]。可用: {available}")
|
|
134
152
|
raise typer.Exit(1)
|
|
153
|
+
if sensor == "gems":
|
|
154
|
+
from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
|
|
155
|
+
|
|
156
|
+
use_res = GEMS_ANALYSIS_RESOLUTION
|
|
135
157
|
use_data_dir = (
|
|
136
158
|
data_dir if data_dir is not None else cfg.get_data_dir("guokong")
|
|
137
159
|
)
|
rsplot/commands/reconstruct.py
CHANGED
|
@@ -54,7 +54,11 @@ def run_reconstruct(
|
|
|
54
54
|
) -> None:
|
|
55
55
|
"""Execute reconstructed-grid orchestration."""
|
|
56
56
|
from rsplot.geo.boundaries import resolve_region
|
|
57
|
-
from rsplot.geo.processing import
|
|
57
|
+
from rsplot.geo.processing import (
|
|
58
|
+
buffered_extent,
|
|
59
|
+
prepare_display_grid,
|
|
60
|
+
prepare_existing_grid,
|
|
61
|
+
)
|
|
58
62
|
from rsplot.plotting.colormaps import STATION_CONCENTRATION_CMAP
|
|
59
63
|
from rsplot.plotting.raster import plot_raster
|
|
60
64
|
from rsplot.plotting.styles import setup_fonts
|
|
@@ -148,10 +152,22 @@ def run_reconstruct(
|
|
|
148
152
|
info.geometry,
|
|
149
153
|
fill_gaps=False,
|
|
150
154
|
)
|
|
151
|
-
|
|
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():
|
|
152
164
|
console.print("[red]目标区域内没有有效重构网格。[/red]")
|
|
153
165
|
raise typer.Exit(1)
|
|
154
|
-
if
|
|
166
|
+
if (
|
|
167
|
+
smooth is not None
|
|
168
|
+
and smooth > 0
|
|
169
|
+
and np.isfinite(prepared.values).any()
|
|
170
|
+
):
|
|
155
171
|
console.print(f" → 高斯平滑 sigma={smooth}")
|
|
156
172
|
prepared = prepare_existing_grid(
|
|
157
173
|
prepared.lon,
|
|
@@ -167,7 +183,12 @@ def run_reconstruct(
|
|
|
167
183
|
f"({prepared.n_valid_region}/{prepared.total_region_pixels})"
|
|
168
184
|
)
|
|
169
185
|
|
|
170
|
-
auto_min, auto_max = auto_forecast_vrange(
|
|
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
|
+
)
|
|
171
192
|
use_vmin = vmin if vmin is not None else auto_min
|
|
172
193
|
use_vmax = vmax if vmax is not None else auto_max
|
|
173
194
|
try:
|
|
@@ -192,7 +213,7 @@ def run_reconstruct(
|
|
|
192
213
|
plot_raster(
|
|
193
214
|
prepared.lon,
|
|
194
215
|
prepared.lat,
|
|
195
|
-
|
|
216
|
+
display_grid,
|
|
196
217
|
info,
|
|
197
218
|
dpi=use_dpi,
|
|
198
219
|
cmap=use_cmap,
|
|
@@ -213,6 +234,7 @@ def run_reconstruct(
|
|
|
213
234
|
LON=prepared.lon,
|
|
214
235
|
LAT=prepared.lat,
|
|
215
236
|
grid=prepared.values,
|
|
237
|
+
display_grid=display_grid,
|
|
216
238
|
vmin=use_vmin,
|
|
217
239
|
vmax=use_vmax,
|
|
218
240
|
output=output,
|
rsplot/config.py
CHANGED
rsplot/geo/boundaries.py
CHANGED
|
@@ -58,6 +58,8 @@ class RegionInfo:
|
|
|
58
58
|
sub_name_field: str = NAME_FIELD
|
|
59
59
|
administrative_level: str | None = None
|
|
60
60
|
subdivision_level: str | None = None
|
|
61
|
+
qualified_name: str | None = None
|
|
62
|
+
members: tuple[RegionInfo, ...] = ()
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _strip_suffix(name: str) -> str:
|
|
@@ -116,7 +118,9 @@ def _load_subdivision_source(
|
|
|
116
118
|
try:
|
|
117
119
|
source = gpd.read_file(path)
|
|
118
120
|
except Exception as exc:
|
|
119
|
-
raise ValueError(
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"无法读取下级行政区边界文件 '{path}': {exc}"
|
|
123
|
+
) from exc
|
|
120
124
|
|
|
121
125
|
if name_field not in source.columns:
|
|
122
126
|
available = ", ".join(str(column) for column in source.columns)
|
|
@@ -137,7 +141,9 @@ def _load_subdivision_source(
|
|
|
137
141
|
normalized = source[[name_field, "geometry"]].rename(
|
|
138
142
|
columns={name_field: NAME_FIELD}
|
|
139
143
|
)
|
|
140
|
-
normalized[NAME_FIELD] =
|
|
144
|
+
normalized[NAME_FIELD] = (
|
|
145
|
+
normalized[NAME_FIELD].astype("string").str.strip()
|
|
146
|
+
)
|
|
141
147
|
normalized = normalized[
|
|
142
148
|
normalized[NAME_FIELD].notna()
|
|
143
149
|
& normalized[NAME_FIELD].ne("")
|
|
@@ -280,6 +286,7 @@ def _resolve_qualified_county(
|
|
|
280
286
|
main_gdf=county_match,
|
|
281
287
|
sub_gdf=None,
|
|
282
288
|
administrative_level=administrative_level,
|
|
289
|
+
qualified_name=f"{canonical_city_name}/{county_match.iloc[0][NAME_FIELD]}",
|
|
283
290
|
)
|
|
284
291
|
|
|
285
292
|
|
|
@@ -451,6 +458,7 @@ def _build_info(
|
|
|
451
458
|
detail_gdf: gpd.GeoDataFrame | None = None,
|
|
452
459
|
administrative_level: str | None = None,
|
|
453
460
|
subdivision_level: str | None = None,
|
|
461
|
+
qualified_name: str | None = None,
|
|
454
462
|
) -> RegionInfo:
|
|
455
463
|
bounds = geometry.bounds # (minx, miny, maxx, maxy)
|
|
456
464
|
params = LEVEL_DEFAULTS[level]
|
|
@@ -471,4 +479,99 @@ def _build_info(
|
|
|
471
479
|
detail_boundary_gdf=detail_gdf,
|
|
472
480
|
administrative_level=administrative_level,
|
|
473
481
|
subdivision_level=subdivision_level,
|
|
482
|
+
qualified_name=qualified_name,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def resolve_regions(
|
|
487
|
+
expression: str,
|
|
488
|
+
cfg: AppConfig,
|
|
489
|
+
level_override: str | None = None,
|
|
490
|
+
) -> RegionInfo:
|
|
491
|
+
"""Resolve a comma-separated selection without changing single-region APIs."""
|
|
492
|
+
if "," not in expression and "," not in expression:
|
|
493
|
+
return resolve_region(expression, cfg, level_override)
|
|
494
|
+
names = [
|
|
495
|
+
part.replace("\r", "").replace("\n", "").strip()
|
|
496
|
+
for part in re.split("[,,]", expression)
|
|
497
|
+
]
|
|
498
|
+
if any(not name for name in names):
|
|
499
|
+
raise ValueError("多区域列表包含空项,请检查连续逗号或首尾逗号。")
|
|
500
|
+
members = []
|
|
501
|
+
seen = set()
|
|
502
|
+
for index, name in enumerate(names, 1):
|
|
503
|
+
try:
|
|
504
|
+
member = resolve_region(name, cfg, level_override)
|
|
505
|
+
except ValueError as error:
|
|
506
|
+
raise ValueError(
|
|
507
|
+
f"第 {index} 个区域 '{name}' 无法解析:{error}"
|
|
508
|
+
) from error
|
|
509
|
+
key = (member.level, member.qualified_name or member.name)
|
|
510
|
+
if key not in seen:
|
|
511
|
+
members.append(member)
|
|
512
|
+
seen.add(key)
|
|
513
|
+
|
|
514
|
+
levels = ("country", "key_region", "province", "city", "county")
|
|
515
|
+
level = min((member.level for member in members), key=levels.index)
|
|
516
|
+
geometry = unary_union([member.geometry for member in members])
|
|
517
|
+
crs = next(
|
|
518
|
+
(
|
|
519
|
+
m.main_boundary_gdf.crs
|
|
520
|
+
for m in members
|
|
521
|
+
if m.main_boundary_gdf is not None
|
|
522
|
+
and m.main_boundary_gdf.crs is not None
|
|
523
|
+
),
|
|
524
|
+
"EPSG:4326",
|
|
474
525
|
)
|
|
526
|
+
main = gpd.GeoDataFrame(
|
|
527
|
+
{NAME_FIELD: [m.qualified_name or m.name for m in members]},
|
|
528
|
+
geometry=[m.geometry for m in members],
|
|
529
|
+
crs=crs,
|
|
530
|
+
)
|
|
531
|
+
sub = _merge_member_layers(members, "sub_boundary_gdf", crs)
|
|
532
|
+
detail = _merge_member_layers(members, "detail_boundary_gdf", crs)
|
|
533
|
+
if detail is not None and sub is not None:
|
|
534
|
+
sub_keys = {g.normalize().wkb for g in sub.geometry}
|
|
535
|
+
detail = detail[
|
|
536
|
+
~detail.geometry.map(lambda g: g.normalize().wkb in sub_keys)
|
|
537
|
+
].copy()
|
|
538
|
+
info = _build_info(
|
|
539
|
+
name=f"联合区域({len(members)}个)",
|
|
540
|
+
level=level,
|
|
541
|
+
geometry=geometry,
|
|
542
|
+
main_gdf=main,
|
|
543
|
+
sub_gdf=sub,
|
|
544
|
+
detail_gdf=detail,
|
|
545
|
+
)
|
|
546
|
+
info.members = tuple(members)
|
|
547
|
+
return info
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _merge_member_layers(
|
|
551
|
+
members: list[RegionInfo],
|
|
552
|
+
attribute: str,
|
|
553
|
+
crs: object,
|
|
554
|
+
) -> gpd.GeoDataFrame | None:
|
|
555
|
+
"""Keep only each member's own subdivisions and deduplicate shared features."""
|
|
556
|
+
rows = []
|
|
557
|
+
seen = set()
|
|
558
|
+
for member in members:
|
|
559
|
+
frame = getattr(member, attribute)
|
|
560
|
+
if frame is None or len(frame) == 0:
|
|
561
|
+
continue
|
|
562
|
+
if frame.crs is not None and frame.crs != crs:
|
|
563
|
+
frame = frame.to_crs(crs)
|
|
564
|
+
for _, row in frame.iterrows():
|
|
565
|
+
geometry = row.geometry.intersection(member.geometry)
|
|
566
|
+
if geometry.is_empty or geometry.area == 0:
|
|
567
|
+
continue
|
|
568
|
+
key = geometry.normalize().wkb
|
|
569
|
+
if key in seen:
|
|
570
|
+
continue
|
|
571
|
+
seen.add(key)
|
|
572
|
+
rows.append(
|
|
573
|
+
{NAME_FIELD: row[member.sub_name_field], "geometry": geometry}
|
|
574
|
+
)
|
|
575
|
+
if not rows:
|
|
576
|
+
return None
|
|
577
|
+
return gpd.GeoDataFrame(rows, geometry="geometry", crs=crs)
|