rsplot 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rsplot-0.2.2 → rsplot-0.2.4}/PKG-INFO +7 -1
- {rsplot-0.2.2 → rsplot-0.2.4}/README.md +6 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/pyproject.toml +1 -1
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/cli.py +124 -44
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/config.py +1 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/fnr.py +56 -6
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/fnr.py +34 -12
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/__init__.py +35 -1
- rsplot-0.2.4/src/rsplot/readers/tropomi_o3pr.py +103 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/results.py +33 -14
- {rsplot-0.2.2 → rsplot-0.2.4}/.gitignore +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/LICENSE +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/boundaries.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/colormaps.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/recent.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/tiles/tianditu.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rsplot
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: CLI tool for plotting Remote Sensing data, designed for Agent.
|
|
5
5
|
Project-URL: Repository, https://git.lug.ustc.edu.cn/yaoyhu/rsplot
|
|
6
6
|
Project-URL: GitHub, https://github.com/yaoyhu/rsplot
|
|
@@ -279,6 +279,9 @@ rsplot raster 合肥 20260115 -p no2 -o ./logs/hcho/raster_city_jiujiang_hcho.pn
|
|
|
279
279
|
# Plot a 30-day TROPOMI window mean.
|
|
280
280
|
rsplot raster 安徽 20260131 -p hcho --days 30 -o hcho_anhui_202601.png
|
|
281
281
|
|
|
282
|
+
# Plot a TROPOMI ozone-profile tropospheric column raster.
|
|
283
|
+
rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
|
|
284
|
+
|
|
282
285
|
# Plot an explicit TROPOMI date range.
|
|
283
286
|
rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
|
|
284
287
|
|
|
@@ -293,6 +296,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
293
296
|
|
|
294
297
|
# Plot an FNR regime map with an explicit date range.
|
|
295
298
|
rsplot fnr 长三角 20260101-20260130 -o fnr_yrd_202601.png
|
|
299
|
+
|
|
300
|
+
# Override FNR regime thresholds for agent-driven sensitivity tests.
|
|
301
|
+
rsplot fnr 合肥 20260114 --voc-threshold 0.8 --nox-threshold 1.8 --display-max 5 -o fnr_hefei_sensitivity.png
|
|
296
302
|
```
|
|
297
303
|
|
|
298
304
|
When `-o/--output` is provided, `rsplot` writes the image to that path and also
|
|
@@ -52,6 +52,9 @@ rsplot raster 合肥 20260115 -p no2 -o ./logs/hcho/raster_city_jiujiang_hcho.pn
|
|
|
52
52
|
# Plot a 30-day TROPOMI window mean.
|
|
53
53
|
rsplot raster 安徽 20260131 -p hcho --days 30 -o hcho_anhui_202601.png
|
|
54
54
|
|
|
55
|
+
# Plot a TROPOMI ozone-profile tropospheric column raster.
|
|
56
|
+
rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
|
|
57
|
+
|
|
55
58
|
# Plot an explicit TROPOMI date range.
|
|
56
59
|
rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
|
|
57
60
|
|
|
@@ -66,6 +69,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
66
69
|
|
|
67
70
|
# Plot an FNR regime map with an explicit date range.
|
|
68
71
|
rsplot fnr 长三角 20260101-20260130 -o fnr_yrd_202601.png
|
|
72
|
+
|
|
73
|
+
# Override FNR regime thresholds for agent-driven sensitivity tests.
|
|
74
|
+
rsplot fnr 合肥 20260114 --voc-threshold 0.8 --nox-threshold 1.8 --display-max 5 -o fnr_hefei_sensitivity.png
|
|
69
75
|
```
|
|
70
76
|
|
|
71
77
|
When `-o/--output` is provided, `rsplot` writes the image to that path and also
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from importlib.metadata import PackageNotFoundError, version
|
|
6
5
|
import math
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
import numpy as np
|
|
@@ -47,17 +47,20 @@ def _default_raster_n_min(product: str, window_n: int) -> int:
|
|
|
47
47
|
return max(3, math.ceil(window_n * 0.25))
|
|
48
48
|
return max(3, window_n // 2)
|
|
49
49
|
|
|
50
|
+
|
|
50
51
|
def _get_version() -> str:
|
|
51
52
|
try:
|
|
52
53
|
return version("rsplot")
|
|
53
54
|
except PackageNotFoundError:
|
|
54
55
|
return "unknown"
|
|
55
56
|
|
|
57
|
+
|
|
56
58
|
def _version_callback(value: bool) -> None:
|
|
57
59
|
if value:
|
|
58
60
|
console.print(f"rsplot {_get_version()}")
|
|
59
61
|
raise typer.Exit()
|
|
60
62
|
|
|
63
|
+
|
|
61
64
|
@app.callback()
|
|
62
65
|
def _callback(
|
|
63
66
|
show_version: bool = typer.Option(
|
|
@@ -86,7 +89,7 @@ def raster(
|
|
|
86
89
|
"o3",
|
|
87
90
|
"--product",
|
|
88
91
|
"-p",
|
|
89
|
-
help="产品类型: no2 / o3 / hcho",
|
|
92
|
+
help="产品类型: no2 / o3 / o3pr / hcho",
|
|
90
93
|
),
|
|
91
94
|
days: int | None = typer.Option(
|
|
92
95
|
None,
|
|
@@ -98,8 +101,8 @@ def raster(
|
|
|
98
101
|
None,
|
|
99
102
|
"--n-min",
|
|
100
103
|
help=(
|
|
101
|
-
"窗口模式下每像元最少有效天数。默认: NO2/O3=
|
|
102
|
-
"HCHO=max(3, ceil(天数*0.25))"
|
|
104
|
+
"窗口模式下每像元最少有效天数。默认: NO2/O3/O3PR="
|
|
105
|
+
"max(3, 天数//2), HCHO=max(3, ceil(天数*0.25))"
|
|
103
106
|
),
|
|
104
107
|
),
|
|
105
108
|
level: str | None = typer.Option(
|
|
@@ -205,12 +208,25 @@ def raster(
|
|
|
205
208
|
|
|
206
209
|
# --- 3. Merge params (level defaults + CLI overrides) ---
|
|
207
210
|
params = info.params
|
|
208
|
-
use_res = res if res is not None else params.res
|
|
209
211
|
use_dpi = dpi if dpi is not None else cfg.dpi
|
|
210
212
|
prod_info = get_product_info(product)
|
|
213
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
214
|
+
use_res = (
|
|
215
|
+
res
|
|
216
|
+
if res is not None
|
|
217
|
+
else product_defaults.res
|
|
218
|
+
if product_defaults.res is not None
|
|
219
|
+
else params.res
|
|
220
|
+
)
|
|
211
221
|
use_cmap = cmap if cmap is not None else prod_info.cmap
|
|
212
222
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
213
|
-
use_smooth =
|
|
223
|
+
use_smooth = (
|
|
224
|
+
smooth
|
|
225
|
+
if smooth is not None
|
|
226
|
+
else product_defaults.smooth_sigma
|
|
227
|
+
if product_defaults.smooth_sigma is not None
|
|
228
|
+
else params.smooth_sigma
|
|
229
|
+
)
|
|
214
230
|
|
|
215
231
|
if basemap is not None:
|
|
216
232
|
use_basemap: bool | None = basemap.lower() == "satellite"
|
|
@@ -313,9 +329,7 @@ def raster(
|
|
|
313
329
|
coverage_days = {
|
|
314
330
|
"min": int(covered.min()) if len(covered) else 0,
|
|
315
331
|
"max": int(covered.max()) if len(covered) else 0,
|
|
316
|
-
"mean": (
|
|
317
|
-
round(float(covered.mean()), 1) if len(covered) else 0.0
|
|
318
|
-
),
|
|
332
|
+
"mean": (round(float(covered.mean()), 1) if len(covered) else 0.0),
|
|
319
333
|
}
|
|
320
334
|
if len(covered):
|
|
321
335
|
coverage_days.update(
|
|
@@ -377,9 +391,7 @@ def raster(
|
|
|
377
391
|
# --- 7. Plot ---
|
|
378
392
|
if output is None:
|
|
379
393
|
if window_mode:
|
|
380
|
-
output =
|
|
381
|
-
f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
382
|
-
)
|
|
394
|
+
output = f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
383
395
|
else:
|
|
384
396
|
output = f"/tmp/rsplot_{info.name}_{dates[0]}.png"
|
|
385
397
|
|
|
@@ -520,17 +532,17 @@ def station(
|
|
|
520
532
|
if resolved_dt != datetime_str:
|
|
521
533
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
522
534
|
console.print(
|
|
523
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
524
|
-
f"(共 {data.n_stations} 站)"
|
|
535
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
525
536
|
)
|
|
526
537
|
|
|
527
538
|
# --- 5. Exceedance threshold ---
|
|
528
539
|
threshold = get_exceedance_threshold(var)
|
|
529
540
|
if threshold is not None:
|
|
530
|
-
n_exceed = int(
|
|
541
|
+
n_exceed = int(
|
|
542
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
543
|
+
)
|
|
531
544
|
console.print(
|
|
532
|
-
f" → 超标阈值: {threshold}, "
|
|
533
|
-
f"超标站点: [red]{n_exceed}[/red]"
|
|
545
|
+
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
534
546
|
)
|
|
535
547
|
|
|
536
548
|
# --- 6. Plot ---
|
|
@@ -590,6 +602,7 @@ def station(
|
|
|
590
602
|
_PRODUCT_STATION_PAIR: dict[str, str] = {
|
|
591
603
|
"no2": "NO2",
|
|
592
604
|
"o3": "O3",
|
|
605
|
+
"o3pr": "O3",
|
|
593
606
|
}
|
|
594
607
|
|
|
595
608
|
|
|
@@ -608,7 +621,7 @@ def overlay(
|
|
|
608
621
|
"o3",
|
|
609
622
|
"--product",
|
|
610
623
|
"-p",
|
|
611
|
-
help="卫星产品类型: no2 / o3",
|
|
624
|
+
help="卫星产品类型: no2 / o3 / o3pr / hcho",
|
|
612
625
|
),
|
|
613
626
|
# --- station variable ---
|
|
614
627
|
var: str | None = typer.Option(
|
|
@@ -648,8 +661,12 @@ def overlay(
|
|
|
648
661
|
"-b",
|
|
649
662
|
help="底图控制: satellite / none (覆盖级别默认)",
|
|
650
663
|
),
|
|
651
|
-
vmin: float | None = typer.Option(
|
|
652
|
-
|
|
664
|
+
vmin: float | None = typer.Option(
|
|
665
|
+
None, "--vmin", help="卫星 colorbar 最小值"
|
|
666
|
+
),
|
|
667
|
+
vmax: float | None = typer.Option(
|
|
668
|
+
None, "--vmax", help="卫星 colorbar 最大值"
|
|
669
|
+
),
|
|
653
670
|
cmap: str | None = typer.Option(None, "--cmap", help="卫星 colormap 名称"),
|
|
654
671
|
smooth: float | None = typer.Option(
|
|
655
672
|
None, "--smooth", help="高斯平滑 sigma"
|
|
@@ -714,12 +731,25 @@ def overlay(
|
|
|
714
731
|
|
|
715
732
|
# --- 5. Merge params ---
|
|
716
733
|
params = info.params
|
|
717
|
-
use_res = res if res is not None else params.res
|
|
718
734
|
use_dpi = dpi if dpi is not None else cfg.dpi
|
|
719
735
|
prod_info = get_product_info(product)
|
|
736
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
737
|
+
use_res = (
|
|
738
|
+
res
|
|
739
|
+
if res is not None
|
|
740
|
+
else product_defaults.res
|
|
741
|
+
if product_defaults.res is not None
|
|
742
|
+
else params.res
|
|
743
|
+
)
|
|
720
744
|
use_cmap = cmap if cmap is not None else prod_info.cmap
|
|
721
745
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
722
|
-
use_smooth =
|
|
746
|
+
use_smooth = (
|
|
747
|
+
smooth
|
|
748
|
+
if smooth is not None
|
|
749
|
+
else product_defaults.smooth_sigma
|
|
750
|
+
if product_defaults.smooth_sigma is not None
|
|
751
|
+
else params.smooth_sigma
|
|
752
|
+
)
|
|
723
753
|
|
|
724
754
|
if basemap is not None:
|
|
725
755
|
use_basemap: bool | None = basemap.lower() == "satellite"
|
|
@@ -744,7 +774,9 @@ def overlay(
|
|
|
744
774
|
info.extent[2] - buf,
|
|
745
775
|
info.extent[3] + buf,
|
|
746
776
|
)
|
|
747
|
-
console.print(
|
|
777
|
+
console.print(
|
|
778
|
+
f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})"
|
|
779
|
+
)
|
|
748
780
|
console.print(f" 日期: {date}, QA > {use_qa}")
|
|
749
781
|
swath = reader.read(use_raster_dir, date, read_extent, qa_threshold=use_qa)
|
|
750
782
|
console.print(
|
|
@@ -799,23 +831,22 @@ def overlay(
|
|
|
799
831
|
if resolved_dt != datetime_str:
|
|
800
832
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
801
833
|
console.print(
|
|
802
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
803
|
-
f"(共 {data.n_stations} 站)"
|
|
834
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
804
835
|
)
|
|
805
836
|
|
|
806
837
|
# --- 10. Exceedance threshold ---
|
|
807
838
|
threshold = get_exceedance_threshold(var)
|
|
808
839
|
if threshold is not None:
|
|
809
|
-
n_exceed = int(
|
|
840
|
+
n_exceed = int(
|
|
841
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
842
|
+
)
|
|
810
843
|
console.print(
|
|
811
844
|
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
812
845
|
)
|
|
813
846
|
|
|
814
847
|
# --- 11. Plot ---
|
|
815
848
|
if output is None:
|
|
816
|
-
output =
|
|
817
|
-
f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
818
|
-
)
|
|
849
|
+
output = f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
819
850
|
|
|
820
851
|
unit = STATION_VAR_META[var][0]
|
|
821
852
|
station_label = f"{var} ({unit})" if unit else var
|
|
@@ -892,7 +923,7 @@ def recent(
|
|
|
892
923
|
"o3",
|
|
893
924
|
"--product",
|
|
894
925
|
"-p",
|
|
895
|
-
help="source=raster 时的产品: no2 / o3 / hcho",
|
|
926
|
+
help="source=raster 时的产品: no2 / o3 / o3pr / hcho",
|
|
896
927
|
),
|
|
897
928
|
var: str = typer.Option(
|
|
898
929
|
"AQI",
|
|
@@ -971,7 +1002,14 @@ def recent(
|
|
|
971
1002
|
|
|
972
1003
|
if use_source == "raster":
|
|
973
1004
|
prod_info = get_product_info(product)
|
|
974
|
-
|
|
1005
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
1006
|
+
use_res = (
|
|
1007
|
+
res
|
|
1008
|
+
if res is not None
|
|
1009
|
+
else product_defaults.res
|
|
1010
|
+
if product_defaults.res is not None
|
|
1011
|
+
else info.params.res
|
|
1012
|
+
)
|
|
975
1013
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
976
1014
|
use_data_dir = (
|
|
977
1015
|
data_dir if data_dir is not None else cfg.get_data_dir(product)
|
|
@@ -1013,9 +1051,7 @@ def recent(
|
|
|
1013
1051
|
data_dir if data_dir is not None else cfg.get_data_dir("guokong")
|
|
1014
1052
|
)
|
|
1015
1053
|
unit = STATION_VAR_META[var][0]
|
|
1016
|
-
console.print(
|
|
1017
|
-
f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})"
|
|
1018
|
-
)
|
|
1054
|
+
console.print(f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})")
|
|
1019
1055
|
daily = summarize_recent_station(
|
|
1020
1056
|
region=info,
|
|
1021
1057
|
data_dir=use_data_dir,
|
|
@@ -1070,6 +1106,21 @@ def fnr(
|
|
|
1070
1106
|
help="最小平均 NO2 柱浓度阈值 (x10^15 molec/cm2);"
|
|
1071
1107
|
"低于此值的像元 FNR 不可信,输出为 NaN",
|
|
1072
1108
|
),
|
|
1109
|
+
voc_threshold: float = typer.Option(
|
|
1110
|
+
1.0,
|
|
1111
|
+
"--voc-threshold",
|
|
1112
|
+
help="VOC-limited 分级阈值:FNR 小于该值判为 VOC-limited",
|
|
1113
|
+
),
|
|
1114
|
+
nox_threshold: float = typer.Option(
|
|
1115
|
+
2.0,
|
|
1116
|
+
"--nox-threshold",
|
|
1117
|
+
help="NOx-limited 分级阈值:FNR 大于该值判为 NOx-limited",
|
|
1118
|
+
),
|
|
1119
|
+
display_max: float = typer.Option(
|
|
1120
|
+
6.0,
|
|
1121
|
+
"--display-max",
|
|
1122
|
+
help="FNR 图例显示上限,必须大于 --nox-threshold",
|
|
1123
|
+
),
|
|
1073
1124
|
# --- overrides ---
|
|
1074
1125
|
level: str | None = typer.Option(
|
|
1075
1126
|
None,
|
|
@@ -1121,7 +1172,11 @@ def fnr(
|
|
|
1121
1172
|
rsplot fnr 安徽 20260131 --days 30 (30日窗口)
|
|
1122
1173
|
rsplot fnr 长三角 20260101-20260130 (显式范围)
|
|
1123
1174
|
"""
|
|
1124
|
-
from rsplot.fnr import
|
|
1175
|
+
from rsplot.fnr import (
|
|
1176
|
+
compute_fnr,
|
|
1177
|
+
parse_date_window,
|
|
1178
|
+
validate_fnr_thresholds,
|
|
1179
|
+
)
|
|
1125
1180
|
from rsplot.geo.boundaries import resolve_region
|
|
1126
1181
|
from rsplot.plotting.fnr import plot_fnr
|
|
1127
1182
|
from rsplot.plotting.styles import setup_fonts
|
|
@@ -1145,11 +1200,27 @@ def fnr(
|
|
|
1145
1200
|
f"[red]--n-min ({use_n_min}) 大于窗口天数 ({window_n})[/red]"
|
|
1146
1201
|
)
|
|
1147
1202
|
raise typer.Exit(1)
|
|
1203
|
+
try:
|
|
1204
|
+
use_voc_threshold, use_nox_threshold, use_display_max = (
|
|
1205
|
+
validate_fnr_thresholds(
|
|
1206
|
+
voc_threshold=voc_threshold,
|
|
1207
|
+
nox_threshold=nox_threshold,
|
|
1208
|
+
display_max=display_max,
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
except ValueError as e:
|
|
1212
|
+
console.print(f"[red]FNR 阈值错误: {e}[/red]")
|
|
1213
|
+
raise typer.Exit(1) from e
|
|
1148
1214
|
|
|
1149
1215
|
console.print(
|
|
1150
1216
|
f"[bold]时间窗口:[/bold] {dates[0]} ~ {dates[-1]} "
|
|
1151
1217
|
f"({window_n} 天,每像元至少 {use_n_min} 天有效)"
|
|
1152
1218
|
)
|
|
1219
|
+
console.print(
|
|
1220
|
+
f"[bold]FNR 分级:[/bold] VOC<{use_voc_threshold:g}, "
|
|
1221
|
+
f"transition {use_voc_threshold:g}~{use_nox_threshold:g}, "
|
|
1222
|
+
f"NOx>{use_nox_threshold:g}"
|
|
1223
|
+
)
|
|
1153
1224
|
|
|
1154
1225
|
# --- 3. Resolve region ---
|
|
1155
1226
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
@@ -1175,7 +1246,9 @@ def fnr(
|
|
|
1175
1246
|
|
|
1176
1247
|
# --- 5. Resolve data dirs ---
|
|
1177
1248
|
use_no2_dir = (
|
|
1178
|
-
raster_dir_no2
|
|
1249
|
+
raster_dir_no2
|
|
1250
|
+
if raster_dir_no2 is not None
|
|
1251
|
+
else cfg.get_data_dir("no2")
|
|
1179
1252
|
)
|
|
1180
1253
|
use_hcho_dir = (
|
|
1181
1254
|
raster_dir_hcho
|
|
@@ -1205,12 +1278,10 @@ def fnr(
|
|
|
1205
1278
|
n_found_no2 = len(fnr_out.dates_found_no2)
|
|
1206
1279
|
n_found_hcho = len(fnr_out.dates_found_hcho)
|
|
1207
1280
|
console.print(
|
|
1208
|
-
f" → NO2 有效天数 {n_found_no2}/{window_n} "
|
|
1209
|
-
f"(失败 {n_fail_no2})"
|
|
1281
|
+
f" → NO2 有效天数 {n_found_no2}/{window_n} (失败 {n_fail_no2})"
|
|
1210
1282
|
)
|
|
1211
1283
|
console.print(
|
|
1212
|
-
f" → HCHO 有效天数 {n_found_hcho}/{window_n} "
|
|
1213
|
-
f"(失败 {n_fail_hcho})"
|
|
1284
|
+
f" → HCHO 有效天数 {n_found_hcho}/{window_n} (失败 {n_fail_hcho})"
|
|
1214
1285
|
)
|
|
1215
1286
|
if use_n_min > min(n_found_no2, n_found_hcho):
|
|
1216
1287
|
console.print(
|
|
@@ -1236,7 +1307,12 @@ def fnr(
|
|
|
1236
1307
|
)
|
|
1237
1308
|
|
|
1238
1309
|
from rsplot.fnr import regime_percentages
|
|
1239
|
-
|
|
1310
|
+
|
|
1311
|
+
pct = regime_percentages(
|
|
1312
|
+
fnr_out.fnr,
|
|
1313
|
+
voc_threshold=use_voc_threshold,
|
|
1314
|
+
nox_threshold=use_nox_threshold,
|
|
1315
|
+
)
|
|
1240
1316
|
console.print(
|
|
1241
1317
|
f" → Regime: [red]VOC-limited {pct['voc_limited']}%[/red] | "
|
|
1242
1318
|
f"[yellow]transition {pct['transition']}%[/yellow] | "
|
|
@@ -1245,9 +1321,7 @@ def fnr(
|
|
|
1245
1321
|
|
|
1246
1322
|
# --- 7. Plot ---
|
|
1247
1323
|
if output is None:
|
|
1248
|
-
output =
|
|
1249
|
-
f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1250
|
-
)
|
|
1324
|
+
output = f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1251
1325
|
|
|
1252
1326
|
subtitle = (
|
|
1253
1327
|
f"{dates[0]}~{dates[-1]} · N_days={window_n} "
|
|
@@ -1266,6 +1340,9 @@ def fnr(
|
|
|
1266
1340
|
title=title,
|
|
1267
1341
|
output=output,
|
|
1268
1342
|
subtitle=subtitle,
|
|
1343
|
+
voc_threshold=use_voc_threshold,
|
|
1344
|
+
nox_threshold=use_nox_threshold,
|
|
1345
|
+
display_max=use_display_max,
|
|
1269
1346
|
)
|
|
1270
1347
|
console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
|
|
1271
1348
|
|
|
@@ -1275,6 +1352,9 @@ def fnr(
|
|
|
1275
1352
|
fnr_out=fnr_out,
|
|
1276
1353
|
res=use_res,
|
|
1277
1354
|
output=output,
|
|
1355
|
+
voc_threshold=use_voc_threshold,
|
|
1356
|
+
nox_threshold=use_nox_threshold,
|
|
1357
|
+
display_max=use_display_max,
|
|
1278
1358
|
)
|
|
1279
1359
|
emit_result(result, output)
|
|
1280
1360
|
|
|
@@ -24,6 +24,7 @@ else:
|
|
|
24
24
|
DEFAULT_DATA_DIRS: dict[str, str] = {
|
|
25
25
|
"no2": "/satellite/exports2/yaoyhu/NO2",
|
|
26
26
|
"o3": "/satellite/exports2/yaoyhu/O3",
|
|
27
|
+
"o3pr": "/satellite/exports2/yaoyhu/O3PR",
|
|
27
28
|
"hcho": "/satellite/exports2/yaoyhu/HCHO",
|
|
28
29
|
"guokong": "/home/zzgsg/data/guokong_combine",
|
|
29
30
|
}
|
|
@@ -60,6 +60,34 @@ NO2_MIN_COLUMN = 0.5
|
|
|
60
60
|
FNR_DISPLAY_MAX = 6.0
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def validate_fnr_thresholds(
|
|
64
|
+
*,
|
|
65
|
+
voc_threshold: float = FNR_VOC_LIMIT,
|
|
66
|
+
nox_threshold: float = FNR_NOX_LIMIT,
|
|
67
|
+
display_max: float = FNR_DISPLAY_MAX,
|
|
68
|
+
) -> tuple[float, float, float]:
|
|
69
|
+
"""Validate FNR regime/display thresholds and return normalized floats."""
|
|
70
|
+
values = {
|
|
71
|
+
"voc_threshold": float(voc_threshold),
|
|
72
|
+
"nox_threshold": float(nox_threshold),
|
|
73
|
+
"display_max": float(display_max),
|
|
74
|
+
}
|
|
75
|
+
for name, value in values.items():
|
|
76
|
+
if not np.isfinite(value):
|
|
77
|
+
raise ValueError(f"{name} 必须是有限数字")
|
|
78
|
+
if values["voc_threshold"] < 0:
|
|
79
|
+
raise ValueError("voc_threshold 必须 >= 0")
|
|
80
|
+
if values["voc_threshold"] >= values["nox_threshold"]:
|
|
81
|
+
raise ValueError("voc_threshold 必须小于 nox_threshold")
|
|
82
|
+
if values["nox_threshold"] >= values["display_max"]:
|
|
83
|
+
raise ValueError("nox_threshold 必须小于 display_max")
|
|
84
|
+
return (
|
|
85
|
+
values["voc_threshold"],
|
|
86
|
+
values["nox_threshold"],
|
|
87
|
+
values["display_max"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
63
91
|
# ---------------------------------------------------------------------------
|
|
64
92
|
# Date window parsing
|
|
65
93
|
# ---------------------------------------------------------------------------
|
|
@@ -361,19 +389,41 @@ def compute_fnr(
|
|
|
361
389
|
# ---------------------------------------------------------------------------
|
|
362
390
|
# Regime helpers (used by the plotter and the JSON builder)
|
|
363
391
|
# ---------------------------------------------------------------------------
|
|
364
|
-
def classify_regime(
|
|
392
|
+
def classify_regime(
|
|
393
|
+
fnr: np.ndarray,
|
|
394
|
+
*,
|
|
395
|
+
voc_threshold: float = FNR_VOC_LIMIT,
|
|
396
|
+
nox_threshold: float = FNR_NOX_LIMIT,
|
|
397
|
+
) -> np.ndarray:
|
|
365
398
|
"""Return int codes: 0=VOC-limited, 1=transition, 2=NOx-limited, -1=NaN."""
|
|
399
|
+
voc_threshold = float(voc_threshold)
|
|
400
|
+
nox_threshold = float(nox_threshold)
|
|
401
|
+
if not np.isfinite(voc_threshold) or not np.isfinite(nox_threshold):
|
|
402
|
+
raise ValueError("FNR regime thresholds 必须是有限数字")
|
|
403
|
+
if voc_threshold < 0:
|
|
404
|
+
raise ValueError("voc_threshold 必须 >= 0")
|
|
405
|
+
if voc_threshold >= nox_threshold:
|
|
406
|
+
raise ValueError("voc_threshold 必须小于 nox_threshold")
|
|
366
407
|
code = np.full(fnr.shape, -1, dtype=np.int8)
|
|
367
408
|
valid = np.isfinite(fnr)
|
|
368
|
-
code[valid & (fnr <
|
|
369
|
-
code[valid & (fnr >=
|
|
370
|
-
code[valid & (fnr >
|
|
409
|
+
code[valid & (fnr < voc_threshold)] = 0
|
|
410
|
+
code[valid & (fnr >= voc_threshold) & (fnr <= nox_threshold)] = 1
|
|
411
|
+
code[valid & (fnr > nox_threshold)] = 2
|
|
371
412
|
return code
|
|
372
413
|
|
|
373
414
|
|
|
374
|
-
def regime_percentages(
|
|
415
|
+
def regime_percentages(
|
|
416
|
+
fnr: np.ndarray,
|
|
417
|
+
*,
|
|
418
|
+
voc_threshold: float = FNR_VOC_LIMIT,
|
|
419
|
+
nox_threshold: float = FNR_NOX_LIMIT,
|
|
420
|
+
) -> dict[str, float]:
|
|
375
421
|
"""% of valid pixels in each regime."""
|
|
376
|
-
code = classify_regime(
|
|
422
|
+
code = classify_regime(
|
|
423
|
+
fnr,
|
|
424
|
+
voc_threshold=voc_threshold,
|
|
425
|
+
nox_threshold=nox_threshold,
|
|
426
|
+
)
|
|
377
427
|
valid_total = int((code >= 0).sum())
|
|
378
428
|
if valid_total == 0:
|
|
379
429
|
return {"voc_limited": 0.0, "transition": 0.0, "nox_limited": 0.0}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Uses the same boundary / basemap / gridline conventions as plot_raster,
|
|
4
4
|
but swaps the continuous colorbar for a 3-segment discrete one aligned
|
|
5
|
-
with the
|
|
6
|
-
|
|
5
|
+
with the active VOC/NOx thresholds so the regime readout is directly
|
|
6
|
+
visible on the map.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -19,7 +19,12 @@ from cartopy.feature import ShapelyFeature
|
|
|
19
19
|
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
|
|
20
20
|
from matplotlib.colors import BoundaryNorm, ListedColormap
|
|
21
21
|
|
|
22
|
-
from rsplot.fnr import
|
|
22
|
+
from rsplot.fnr import (
|
|
23
|
+
FNR_DISPLAY_MAX,
|
|
24
|
+
FNR_NOX_LIMIT,
|
|
25
|
+
FNR_VOC_LIMIT,
|
|
26
|
+
validate_fnr_thresholds,
|
|
27
|
+
)
|
|
23
28
|
from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
|
|
24
29
|
from rsplot.tiles.tianditu import TianDiTuTiles
|
|
25
30
|
|
|
@@ -36,10 +41,15 @@ _REGIME_COLORS = [
|
|
|
36
41
|
]
|
|
37
42
|
|
|
38
43
|
|
|
39
|
-
def _build_fnr_cmap(
|
|
44
|
+
def _build_fnr_cmap(
|
|
45
|
+
*,
|
|
46
|
+
voc_threshold: float,
|
|
47
|
+
nox_threshold: float,
|
|
48
|
+
display_max: float,
|
|
49
|
+
) -> tuple[ListedColormap, BoundaryNorm]:
|
|
40
50
|
cmap = ListedColormap(_REGIME_COLORS, name="fnr_regime")
|
|
41
51
|
cmap.set_bad(color="#dddddd", alpha=0.0) # NaN → transparent
|
|
42
|
-
bounds = [0.0,
|
|
52
|
+
bounds = [0.0, voc_threshold, nox_threshold, display_max]
|
|
43
53
|
norm = BoundaryNorm(bounds, ncolors=cmap.N)
|
|
44
54
|
return cmap, norm
|
|
45
55
|
|
|
@@ -56,8 +66,16 @@ def plot_fnr(
|
|
|
56
66
|
title: str | None = None,
|
|
57
67
|
output: str | None = None,
|
|
58
68
|
subtitle: str | None = None,
|
|
69
|
+
voc_threshold: float = FNR_VOC_LIMIT,
|
|
70
|
+
nox_threshold: float = FNR_NOX_LIMIT,
|
|
71
|
+
display_max: float = FNR_DISPLAY_MAX,
|
|
59
72
|
) -> None:
|
|
60
73
|
"""Draw an FNR regime map."""
|
|
74
|
+
voc_threshold, nox_threshold, display_max = validate_fnr_thresholds(
|
|
75
|
+
voc_threshold=voc_threshold,
|
|
76
|
+
nox_threshold=nox_threshold,
|
|
77
|
+
display_max=display_max,
|
|
78
|
+
)
|
|
61
79
|
params = region.params
|
|
62
80
|
use_basemap = basemap if basemap is not None else params.basemap
|
|
63
81
|
|
|
@@ -74,7 +92,11 @@ def plot_fnr(
|
|
|
74
92
|
subplot_kw={"projection": projection},
|
|
75
93
|
)
|
|
76
94
|
|
|
77
|
-
cmap, norm = _build_fnr_cmap(
|
|
95
|
+
cmap, norm = _build_fnr_cmap(
|
|
96
|
+
voc_threshold=voc_threshold,
|
|
97
|
+
nox_threshold=nox_threshold,
|
|
98
|
+
display_max=display_max,
|
|
99
|
+
)
|
|
78
100
|
alpha = 0.65 if use_basemap else 0.9
|
|
79
101
|
im = ax.pcolormesh(
|
|
80
102
|
LON,
|
|
@@ -160,16 +182,16 @@ def plot_fnr(
|
|
|
160
182
|
shrink=shrink,
|
|
161
183
|
pad=0.05,
|
|
162
184
|
ticks=[
|
|
163
|
-
|
|
164
|
-
(
|
|
165
|
-
(
|
|
185
|
+
voc_threshold / 2,
|
|
186
|
+
(voc_threshold + nox_threshold) / 2,
|
|
187
|
+
(nox_threshold + display_max) / 2,
|
|
166
188
|
],
|
|
167
189
|
)
|
|
168
190
|
cb.ax.set_yticklabels(
|
|
169
191
|
[
|
|
170
|
-
f"VOC-limited\n(<{
|
|
171
|
-
f"Transition\n({
|
|
172
|
-
f"NOx-limited\n(>{
|
|
192
|
+
f"VOC-limited\n(<{voc_threshold:g})",
|
|
193
|
+
f"Transition\n({voc_threshold:g}-{nox_threshold:g})",
|
|
194
|
+
f"NOx-limited\n(>{nox_threshold:g})",
|
|
173
195
|
],
|
|
174
196
|
fontsize=8,
|
|
175
197
|
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import math
|
|
6
|
-
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
@@ -11,10 +11,19 @@ from rsplot.readers.base import BaseReader
|
|
|
11
11
|
from rsplot.readers.tropomi_hcho import TropomiHCHOReader
|
|
12
12
|
from rsplot.readers.tropomi_no2 import TropomiNO2Reader
|
|
13
13
|
from rsplot.readers.tropomi_o3 import TropomiO3Reader
|
|
14
|
+
from rsplot.readers.tropomi_o3pr import TropomiO3PRReader
|
|
14
15
|
|
|
15
16
|
HCHO_CMAP = "rsplot_hcho"
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProductPlotDefaults:
|
|
21
|
+
"""Optional per-product plotting defaults by administrative level."""
|
|
22
|
+
|
|
23
|
+
res: float | None = None
|
|
24
|
+
smooth_sigma: float | None = None
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
@dataclass
|
|
19
28
|
class ProductInfo:
|
|
20
29
|
"""Per-product metadata: reader class, display settings, auto-range config."""
|
|
@@ -25,6 +34,13 @@ class ProductInfo:
|
|
|
25
34
|
vmin: float | None # None = auto from data percentiles
|
|
26
35
|
vmax: float | None
|
|
27
36
|
min_range: float # minimum vmax-vmin to avoid exaggerating small differences
|
|
37
|
+
level_defaults: dict[str, ProductPlotDefaults] = field(
|
|
38
|
+
default_factory=dict
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def defaults_for_level(self, level: str) -> ProductPlotDefaults:
|
|
42
|
+
"""Return product-specific defaults for an administrative level."""
|
|
43
|
+
return self.level_defaults.get(level, ProductPlotDefaults())
|
|
28
44
|
|
|
29
45
|
|
|
30
46
|
PRODUCT_REGISTRY: dict[str, ProductInfo] = {
|
|
@@ -44,6 +60,24 @@ PRODUCT_REGISTRY: dict[str, ProductInfo] = {
|
|
|
44
60
|
vmax=None,
|
|
45
61
|
min_range=30.0,
|
|
46
62
|
),
|
|
63
|
+
"o3pr": ProductInfo(
|
|
64
|
+
reader_cls=TropomiO3PRReader,
|
|
65
|
+
colorbar_label="O3 Tropospheric Column from Profile (DU)",
|
|
66
|
+
cmap="YlOrRd",
|
|
67
|
+
vmin=None,
|
|
68
|
+
vmax=None,
|
|
69
|
+
min_range=5.0,
|
|
70
|
+
level_defaults={
|
|
71
|
+
# O3__PR is a coarse 30x30 km profile product. These defaults
|
|
72
|
+
# keep map pixels close to the native sampling while still using
|
|
73
|
+
# modest smoothing for display.
|
|
74
|
+
"country": ProductPlotDefaults(res=0.25, smooth_sigma=1.0),
|
|
75
|
+
"key_region": ProductPlotDefaults(res=0.15, smooth_sigma=1.2),
|
|
76
|
+
"province": ProductPlotDefaults(res=0.1, smooth_sigma=1.5),
|
|
77
|
+
"city": ProductPlotDefaults(res=0.075, smooth_sigma=1.5),
|
|
78
|
+
"county": ProductPlotDefaults(res=0.05, smooth_sigma=2.0),
|
|
79
|
+
},
|
|
80
|
+
),
|
|
47
81
|
"hcho": ProductInfo(
|
|
48
82
|
reader_cls=TropomiHCHOReader,
|
|
49
83
|
colorbar_label="HCHO VCD (x10^15 molec/cm2)",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""TROPOMI O3 profile tropospheric-column reader.
|
|
2
|
+
|
|
3
|
+
Reads ``S5P_*_L2__O3__PR`` ozone profile products and uses the provided
|
|
4
|
+
``ozone_tropospheric_column`` variable directly. Values are converted from
|
|
5
|
+
``mol m-2`` to DU.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import glob
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from rich.progress import track
|
|
15
|
+
|
|
16
|
+
from rsplot.readers.base import (
|
|
17
|
+
BaseReader,
|
|
18
|
+
SwathData,
|
|
19
|
+
read_tropomi_product_arrays,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
VALUE_NAME = "ozone_tropospheric_column"
|
|
23
|
+
|
|
24
|
+
# mol/m2 -> DU, from the product attribute
|
|
25
|
+
# multiplication_factor_to_convert_to_DU = 2241.15
|
|
26
|
+
UNIT_CONVERSION = 2241.15
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TropomiO3PRReader(BaseReader):
|
|
30
|
+
"""Reader for TROPOMI L2 O3 profile tropospheric-column files."""
|
|
31
|
+
|
|
32
|
+
def read(
|
|
33
|
+
self,
|
|
34
|
+
data_dir: str,
|
|
35
|
+
date: str,
|
|
36
|
+
extent: tuple[float, float, float, float],
|
|
37
|
+
qa_threshold: float = 0.5,
|
|
38
|
+
) -> SwathData:
|
|
39
|
+
patterns = [
|
|
40
|
+
os.path.join(data_dir, f"S5P_*_L2__O3__PR_{date}T*.nc"),
|
|
41
|
+
os.path.join(data_dir, f"S5P_*_L2__O3__PR_{date}T*.nc.zip"),
|
|
42
|
+
]
|
|
43
|
+
files: list[str] = []
|
|
44
|
+
for p in patterns:
|
|
45
|
+
files.extend(glob.glob(p))
|
|
46
|
+
files = sorted(set(files))
|
|
47
|
+
if not files:
|
|
48
|
+
raise FileNotFoundError(
|
|
49
|
+
f"未找到匹配文件:\n "
|
|
50
|
+
+ "\n ".join(patterns)
|
|
51
|
+
+ "\n请检查 --data-dir 和 --date 参数。"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
55
|
+
all_lon, all_lat, all_o3 = [], [], []
|
|
56
|
+
broken = 0
|
|
57
|
+
broken_examples: list[str] = []
|
|
58
|
+
|
|
59
|
+
for f in track(files, description="[cyan]读取 O3PR 轨道..."):
|
|
60
|
+
try:
|
|
61
|
+
lon, lat, o3, qa = read_tropomi_product_arrays(f, VALUE_NAME)
|
|
62
|
+
except (OSError, ValueError, KeyError) as e:
|
|
63
|
+
broken += 1
|
|
64
|
+
if len(broken_examples) < 3:
|
|
65
|
+
broken_examples.append(
|
|
66
|
+
f"{os.path.basename(f)}: {str(e).splitlines()[0]}"
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
mask = (
|
|
71
|
+
(qa > qa_threshold)
|
|
72
|
+
& (lon >= lon_min)
|
|
73
|
+
& (lon <= lon_max)
|
|
74
|
+
& (lat >= lat_min)
|
|
75
|
+
& (lat <= lat_max)
|
|
76
|
+
& np.isfinite(o3)
|
|
77
|
+
& (o3 < 1e30)
|
|
78
|
+
)
|
|
79
|
+
if mask.sum() > 0:
|
|
80
|
+
all_lon.append(lon[mask])
|
|
81
|
+
all_lat.append(lat[mask])
|
|
82
|
+
all_o3.append(o3[mask] * UNIT_CONVERSION)
|
|
83
|
+
|
|
84
|
+
if not all_lon:
|
|
85
|
+
broken_msg = (
|
|
86
|
+
"\n损坏/不可读示例:\n " + "\n ".join(broken_examples)
|
|
87
|
+
if broken_examples
|
|
88
|
+
else ""
|
|
89
|
+
)
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{date} 当天 O3PR 无有效数据覆盖目标区域 {extent}。\n"
|
|
92
|
+
f"共扫描 {len(files)} 个文件,损坏 {broken} 个。"
|
|
93
|
+
f"{broken_msg}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return SwathData(
|
|
97
|
+
lon=np.concatenate(all_lon),
|
|
98
|
+
lat=np.concatenate(all_lat),
|
|
99
|
+
values=np.concatenate(all_o3),
|
|
100
|
+
n_pixels=sum(len(a) for a in all_lon),
|
|
101
|
+
n_files=len(files),
|
|
102
|
+
n_broken=broken,
|
|
103
|
+
)
|
|
@@ -13,6 +13,8 @@ from typing import TYPE_CHECKING, Any
|
|
|
13
13
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
|
|
16
|
+
from rsplot.fnr import FNR_DISPLAY_MAX, FNR_NOX_LIMIT, FNR_VOC_LIMIT
|
|
17
|
+
|
|
16
18
|
if TYPE_CHECKING:
|
|
17
19
|
import geopandas as gpd
|
|
18
20
|
|
|
@@ -475,12 +477,13 @@ def _fnr_per_city_stats(
|
|
|
475
477
|
LAT: np.ndarray,
|
|
476
478
|
fnr: np.ndarray,
|
|
477
479
|
cities_gdf: gpd.GeoDataFrame,
|
|
480
|
+
*,
|
|
481
|
+
voc_threshold: float,
|
|
482
|
+
nox_threshold: float,
|
|
478
483
|
) -> list[dict[str, Any]]:
|
|
479
484
|
"""Per-city mean FNR and dominant regime."""
|
|
480
485
|
from shapely.vectorized import contains
|
|
481
486
|
|
|
482
|
-
from rsplot.fnr import FNR_NOX_LIMIT, FNR_VOC_LIMIT
|
|
483
|
-
|
|
484
487
|
rows: list[dict[str, Any]] = []
|
|
485
488
|
for _, row in cities_gdf.iterrows():
|
|
486
489
|
mask = contains(row.geometry, LON, LAT)
|
|
@@ -489,9 +492,9 @@ def _fnr_per_city_stats(
|
|
|
489
492
|
if len(finite) == 0:
|
|
490
493
|
continue
|
|
491
494
|
mean_fnr = float(np.mean(finite))
|
|
492
|
-
if mean_fnr <
|
|
495
|
+
if mean_fnr < voc_threshold:
|
|
493
496
|
regime = "voc_limited"
|
|
494
|
-
elif mean_fnr <=
|
|
497
|
+
elif mean_fnr <= nox_threshold:
|
|
495
498
|
regime = "transition"
|
|
496
499
|
else:
|
|
497
500
|
regime = "nox_limited"
|
|
@@ -503,10 +506,10 @@ def _fnr_per_city_stats(
|
|
|
503
506
|
"median_fnr": _round(np.median(finite)),
|
|
504
507
|
"regime": regime,
|
|
505
508
|
"voc_limited_pct": round(
|
|
506
|
-
100 * float((finite <
|
|
509
|
+
100 * float((finite < voc_threshold).mean()), 1
|
|
507
510
|
),
|
|
508
511
|
"nox_limited_pct": round(
|
|
509
|
-
100 * float((finite >
|
|
512
|
+
100 * float((finite > nox_threshold).mean()), 1
|
|
510
513
|
),
|
|
511
514
|
}
|
|
512
515
|
)
|
|
@@ -520,13 +523,20 @@ def build_fnr_result(
|
|
|
520
523
|
fnr_out: FnrResult,
|
|
521
524
|
res: float,
|
|
522
525
|
output: str,
|
|
526
|
+
voc_threshold: float = FNR_VOC_LIMIT,
|
|
527
|
+
nox_threshold: float = FNR_NOX_LIMIT,
|
|
528
|
+
display_max: float = FNR_DISPLAY_MAX,
|
|
523
529
|
) -> dict[str, Any]:
|
|
524
530
|
"""Build the JSON sidecar for an FNR run."""
|
|
525
531
|
from rsplot.fnr import (
|
|
526
|
-
FNR_DISPLAY_MAX,
|
|
527
|
-
FNR_NOX_LIMIT,
|
|
528
|
-
FNR_VOC_LIMIT,
|
|
529
532
|
regime_percentages,
|
|
533
|
+
validate_fnr_thresholds,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
voc_threshold, nox_threshold, display_max = validate_fnr_thresholds(
|
|
537
|
+
voc_threshold=voc_threshold,
|
|
538
|
+
nox_threshold=nox_threshold,
|
|
539
|
+
display_max=display_max,
|
|
530
540
|
)
|
|
531
541
|
|
|
532
542
|
fnr = fnr_out.fnr
|
|
@@ -585,9 +595,9 @@ def build_fnr_result(
|
|
|
585
595
|
"params": {
|
|
586
596
|
"n_min": fnr_out.n_min,
|
|
587
597
|
"no2_min_column": fnr_out.no2_min_column,
|
|
588
|
-
"voc_threshold":
|
|
589
|
-
"nox_threshold":
|
|
590
|
-
"display_max":
|
|
598
|
+
"voc_threshold": voc_threshold,
|
|
599
|
+
"nox_threshold": nox_threshold,
|
|
600
|
+
"display_max": display_max,
|
|
591
601
|
"resolution_deg": res,
|
|
592
602
|
},
|
|
593
603
|
"stats": {
|
|
@@ -599,7 +609,11 @@ def build_fnr_result(
|
|
|
599
609
|
"mask_diagnostics": mask_diagnostics,
|
|
600
610
|
**_basic_stats(fnr),
|
|
601
611
|
},
|
|
602
|
-
"regime_pct": regime_percentages(
|
|
612
|
+
"regime_pct": regime_percentages(
|
|
613
|
+
fnr,
|
|
614
|
+
voc_threshold=voc_threshold,
|
|
615
|
+
nox_threshold=nox_threshold,
|
|
616
|
+
),
|
|
603
617
|
"mean_fields": {
|
|
604
618
|
"hcho_mean_raw_stats": _basic_stats(fnr_out.hcho_mean),
|
|
605
619
|
"no2_mean_raw_stats": _basic_stats(fnr_out.no2_mean),
|
|
@@ -611,7 +625,12 @@ def build_fnr_result(
|
|
|
611
625
|
sub_gdf = region.sub_boundary_gdf
|
|
612
626
|
if sub_gdf is not None and len(sub_gdf) > 0:
|
|
613
627
|
result["city_ranking"] = _fnr_per_city_stats(
|
|
614
|
-
fnr_out.LON,
|
|
628
|
+
fnr_out.LON,
|
|
629
|
+
fnr_out.LAT,
|
|
630
|
+
fnr,
|
|
631
|
+
sub_gdf,
|
|
632
|
+
voc_threshold=voc_threshold,
|
|
633
|
+
nox_threshold=nox_threshold,
|
|
615
634
|
)
|
|
616
635
|
|
|
617
636
|
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|