rsplot 0.2.3__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.3 → rsplot-0.2.4}/PKG-INFO +4 -1
- {rsplot-0.2.3 → rsplot-0.2.4}/README.md +3 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/pyproject.toml +1 -1
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/cli.py +80 -34
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/fnr.py +56 -6
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/fnr.py +34 -12
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/results.py +33 -14
- {rsplot-0.2.3 → rsplot-0.2.4}/.gitignore +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/LICENSE +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/config.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/boundaries.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/colormaps.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3pr.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/recent.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.3 → 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
|
|
@@ -296,6 +296,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
296
296
|
|
|
297
297
|
# Plot an FNR regime map with an explicit date range.
|
|
298
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
|
|
299
302
|
```
|
|
300
303
|
|
|
301
304
|
When `-o/--output` is provided, `rsplot` writes the image to that path and also
|
|
@@ -69,6 +69,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
69
69
|
|
|
70
70
|
# Plot an FNR regime map with an explicit date range.
|
|
71
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
|
|
72
75
|
```
|
|
73
76
|
|
|
74
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(
|
|
@@ -326,9 +329,7 @@ def raster(
|
|
|
326
329
|
coverage_days = {
|
|
327
330
|
"min": int(covered.min()) if len(covered) else 0,
|
|
328
331
|
"max": int(covered.max()) if len(covered) else 0,
|
|
329
|
-
"mean": (
|
|
330
|
-
round(float(covered.mean()), 1) if len(covered) else 0.0
|
|
331
|
-
),
|
|
332
|
+
"mean": (round(float(covered.mean()), 1) if len(covered) else 0.0),
|
|
332
333
|
}
|
|
333
334
|
if len(covered):
|
|
334
335
|
coverage_days.update(
|
|
@@ -390,9 +391,7 @@ def raster(
|
|
|
390
391
|
# --- 7. Plot ---
|
|
391
392
|
if output is None:
|
|
392
393
|
if window_mode:
|
|
393
|
-
output =
|
|
394
|
-
f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
395
|
-
)
|
|
394
|
+
output = f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
396
395
|
else:
|
|
397
396
|
output = f"/tmp/rsplot_{info.name}_{dates[0]}.png"
|
|
398
397
|
|
|
@@ -533,17 +532,17 @@ def station(
|
|
|
533
532
|
if resolved_dt != datetime_str:
|
|
534
533
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
535
534
|
console.print(
|
|
536
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
537
|
-
f"(共 {data.n_stations} 站)"
|
|
535
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
538
536
|
)
|
|
539
537
|
|
|
540
538
|
# --- 5. Exceedance threshold ---
|
|
541
539
|
threshold = get_exceedance_threshold(var)
|
|
542
540
|
if threshold is not None:
|
|
543
|
-
n_exceed = int(
|
|
541
|
+
n_exceed = int(
|
|
542
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
543
|
+
)
|
|
544
544
|
console.print(
|
|
545
|
-
f" → 超标阈值: {threshold}, "
|
|
546
|
-
f"超标站点: [red]{n_exceed}[/red]"
|
|
545
|
+
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
547
546
|
)
|
|
548
547
|
|
|
549
548
|
# --- 6. Plot ---
|
|
@@ -662,8 +661,12 @@ def overlay(
|
|
|
662
661
|
"-b",
|
|
663
662
|
help="底图控制: satellite / none (覆盖级别默认)",
|
|
664
663
|
),
|
|
665
|
-
vmin: float | None = typer.Option(
|
|
666
|
-
|
|
664
|
+
vmin: float | None = typer.Option(
|
|
665
|
+
None, "--vmin", help="卫星 colorbar 最小值"
|
|
666
|
+
),
|
|
667
|
+
vmax: float | None = typer.Option(
|
|
668
|
+
None, "--vmax", help="卫星 colorbar 最大值"
|
|
669
|
+
),
|
|
667
670
|
cmap: str | None = typer.Option(None, "--cmap", help="卫星 colormap 名称"),
|
|
668
671
|
smooth: float | None = typer.Option(
|
|
669
672
|
None, "--smooth", help="高斯平滑 sigma"
|
|
@@ -771,7 +774,9 @@ def overlay(
|
|
|
771
774
|
info.extent[2] - buf,
|
|
772
775
|
info.extent[3] + buf,
|
|
773
776
|
)
|
|
774
|
-
console.print(
|
|
777
|
+
console.print(
|
|
778
|
+
f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})"
|
|
779
|
+
)
|
|
775
780
|
console.print(f" 日期: {date}, QA > {use_qa}")
|
|
776
781
|
swath = reader.read(use_raster_dir, date, read_extent, qa_threshold=use_qa)
|
|
777
782
|
console.print(
|
|
@@ -826,23 +831,22 @@ def overlay(
|
|
|
826
831
|
if resolved_dt != datetime_str:
|
|
827
832
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
828
833
|
console.print(
|
|
829
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
830
|
-
f"(共 {data.n_stations} 站)"
|
|
834
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
831
835
|
)
|
|
832
836
|
|
|
833
837
|
# --- 10. Exceedance threshold ---
|
|
834
838
|
threshold = get_exceedance_threshold(var)
|
|
835
839
|
if threshold is not None:
|
|
836
|
-
n_exceed = int(
|
|
840
|
+
n_exceed = int(
|
|
841
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
842
|
+
)
|
|
837
843
|
console.print(
|
|
838
844
|
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
839
845
|
)
|
|
840
846
|
|
|
841
847
|
# --- 11. Plot ---
|
|
842
848
|
if output is None:
|
|
843
|
-
output =
|
|
844
|
-
f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
845
|
-
)
|
|
849
|
+
output = f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
846
850
|
|
|
847
851
|
unit = STATION_VAR_META[var][0]
|
|
848
852
|
station_label = f"{var} ({unit})" if unit else var
|
|
@@ -1047,9 +1051,7 @@ def recent(
|
|
|
1047
1051
|
data_dir if data_dir is not None else cfg.get_data_dir("guokong")
|
|
1048
1052
|
)
|
|
1049
1053
|
unit = STATION_VAR_META[var][0]
|
|
1050
|
-
console.print(
|
|
1051
|
-
f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})"
|
|
1052
|
-
)
|
|
1054
|
+
console.print(f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})")
|
|
1053
1055
|
daily = summarize_recent_station(
|
|
1054
1056
|
region=info,
|
|
1055
1057
|
data_dir=use_data_dir,
|
|
@@ -1104,6 +1106,21 @@ def fnr(
|
|
|
1104
1106
|
help="最小平均 NO2 柱浓度阈值 (x10^15 molec/cm2);"
|
|
1105
1107
|
"低于此值的像元 FNR 不可信,输出为 NaN",
|
|
1106
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
|
+
),
|
|
1107
1124
|
# --- overrides ---
|
|
1108
1125
|
level: str | None = typer.Option(
|
|
1109
1126
|
None,
|
|
@@ -1155,7 +1172,11 @@ def fnr(
|
|
|
1155
1172
|
rsplot fnr 安徽 20260131 --days 30 (30日窗口)
|
|
1156
1173
|
rsplot fnr 长三角 20260101-20260130 (显式范围)
|
|
1157
1174
|
"""
|
|
1158
|
-
from rsplot.fnr import
|
|
1175
|
+
from rsplot.fnr import (
|
|
1176
|
+
compute_fnr,
|
|
1177
|
+
parse_date_window,
|
|
1178
|
+
validate_fnr_thresholds,
|
|
1179
|
+
)
|
|
1159
1180
|
from rsplot.geo.boundaries import resolve_region
|
|
1160
1181
|
from rsplot.plotting.fnr import plot_fnr
|
|
1161
1182
|
from rsplot.plotting.styles import setup_fonts
|
|
@@ -1179,11 +1200,27 @@ def fnr(
|
|
|
1179
1200
|
f"[red]--n-min ({use_n_min}) 大于窗口天数 ({window_n})[/red]"
|
|
1180
1201
|
)
|
|
1181
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
|
|
1182
1214
|
|
|
1183
1215
|
console.print(
|
|
1184
1216
|
f"[bold]时间窗口:[/bold] {dates[0]} ~ {dates[-1]} "
|
|
1185
1217
|
f"({window_n} 天,每像元至少 {use_n_min} 天有效)"
|
|
1186
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
|
+
)
|
|
1187
1224
|
|
|
1188
1225
|
# --- 3. Resolve region ---
|
|
1189
1226
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
@@ -1209,7 +1246,9 @@ def fnr(
|
|
|
1209
1246
|
|
|
1210
1247
|
# --- 5. Resolve data dirs ---
|
|
1211
1248
|
use_no2_dir = (
|
|
1212
|
-
raster_dir_no2
|
|
1249
|
+
raster_dir_no2
|
|
1250
|
+
if raster_dir_no2 is not None
|
|
1251
|
+
else cfg.get_data_dir("no2")
|
|
1213
1252
|
)
|
|
1214
1253
|
use_hcho_dir = (
|
|
1215
1254
|
raster_dir_hcho
|
|
@@ -1239,12 +1278,10 @@ def fnr(
|
|
|
1239
1278
|
n_found_no2 = len(fnr_out.dates_found_no2)
|
|
1240
1279
|
n_found_hcho = len(fnr_out.dates_found_hcho)
|
|
1241
1280
|
console.print(
|
|
1242
|
-
f" → NO2 有效天数 {n_found_no2}/{window_n} "
|
|
1243
|
-
f"(失败 {n_fail_no2})"
|
|
1281
|
+
f" → NO2 有效天数 {n_found_no2}/{window_n} (失败 {n_fail_no2})"
|
|
1244
1282
|
)
|
|
1245
1283
|
console.print(
|
|
1246
|
-
f" → HCHO 有效天数 {n_found_hcho}/{window_n} "
|
|
1247
|
-
f"(失败 {n_fail_hcho})"
|
|
1284
|
+
f" → HCHO 有效天数 {n_found_hcho}/{window_n} (失败 {n_fail_hcho})"
|
|
1248
1285
|
)
|
|
1249
1286
|
if use_n_min > min(n_found_no2, n_found_hcho):
|
|
1250
1287
|
console.print(
|
|
@@ -1270,7 +1307,12 @@ def fnr(
|
|
|
1270
1307
|
)
|
|
1271
1308
|
|
|
1272
1309
|
from rsplot.fnr import regime_percentages
|
|
1273
|
-
|
|
1310
|
+
|
|
1311
|
+
pct = regime_percentages(
|
|
1312
|
+
fnr_out.fnr,
|
|
1313
|
+
voc_threshold=use_voc_threshold,
|
|
1314
|
+
nox_threshold=use_nox_threshold,
|
|
1315
|
+
)
|
|
1274
1316
|
console.print(
|
|
1275
1317
|
f" → Regime: [red]VOC-limited {pct['voc_limited']}%[/red] | "
|
|
1276
1318
|
f"[yellow]transition {pct['transition']}%[/yellow] | "
|
|
@@ -1279,9 +1321,7 @@ def fnr(
|
|
|
1279
1321
|
|
|
1280
1322
|
# --- 7. Plot ---
|
|
1281
1323
|
if output is None:
|
|
1282
|
-
output =
|
|
1283
|
-
f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1284
|
-
)
|
|
1324
|
+
output = f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1285
1325
|
|
|
1286
1326
|
subtitle = (
|
|
1287
1327
|
f"{dates[0]}~{dates[-1]} · N_days={window_n} "
|
|
@@ -1300,6 +1340,9 @@ def fnr(
|
|
|
1300
1340
|
title=title,
|
|
1301
1341
|
output=output,
|
|
1302
1342
|
subtitle=subtitle,
|
|
1343
|
+
voc_threshold=use_voc_threshold,
|
|
1344
|
+
nox_threshold=use_nox_threshold,
|
|
1345
|
+
display_max=use_display_max,
|
|
1303
1346
|
)
|
|
1304
1347
|
console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
|
|
1305
1348
|
|
|
@@ -1309,6 +1352,9 @@ def fnr(
|
|
|
1309
1352
|
fnr_out=fnr_out,
|
|
1310
1353
|
res=use_res,
|
|
1311
1354
|
output=output,
|
|
1355
|
+
voc_threshold=use_voc_threshold,
|
|
1356
|
+
nox_threshold=use_nox_threshold,
|
|
1357
|
+
display_max=use_display_max,
|
|
1312
1358
|
)
|
|
1313
1359
|
emit_result(result, output)
|
|
1314
1360
|
|
|
@@ -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
|
)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|