rsplot 0.2.3__tar.gz → 0.2.5__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.5}/PKG-INFO +52 -4
- {rsplot-0.2.3 → rsplot-0.2.5}/README.md +51 -3
- {rsplot-0.2.3 → rsplot-0.2.5}/pyproject.toml +1 -1
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/cli.py +85 -37
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/config.py +100 -32
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/fnr.py +56 -6
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/boundaries.py +6 -6
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/colormaps.py +9 -10
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/fnr.py +34 -12
- rsplot-0.2.5/src/rsplot/plotting/meteo.py +422 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/overlay.py +23 -8
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/raster.py +2 -1
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/styles.py +9 -2
- rsplot-0.2.5/src/rsplot/readers/era5.py +477 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/guokong.py +7 -7
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/results.py +33 -14
- {rsplot-0.2.3 → rsplot-0.2.5}/.gitignore +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/LICENSE +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_o3pr.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/recent.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.3 → rsplot-0.2.5}/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.5
|
|
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
|
|
@@ -258,16 +258,61 @@ uv run rsplot --help
|
|
|
258
258
|
|
|
259
259
|
## Configuration
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
`rsplot` does not ship site-specific data paths. Configure local satellite,
|
|
262
|
+
station, boundary, and font paths with a TOML file:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
mkdir -p ~/.config/rsplot
|
|
266
|
+
cp config.example.toml ~/.config/rsplot/config.toml
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Then edit `~/.config/rsplot/config.toml`:
|
|
270
|
+
|
|
271
|
+
```toml
|
|
272
|
+
[paths]
|
|
273
|
+
geojson_dir = "/path/to/geojson"
|
|
274
|
+
font_dir = "/path/to/fonts"
|
|
275
|
+
# hcho_cmap_file = "/path/to/256x3_rgb_table.txt"
|
|
276
|
+
|
|
277
|
+
[paths.data_dirs]
|
|
278
|
+
no2 = "/path/to/NO2"
|
|
279
|
+
o3 = "/path/to/O3"
|
|
280
|
+
o3pr = "/path/to/O3PR"
|
|
281
|
+
hcho = "/path/to/HCHO"
|
|
282
|
+
guokong = "/path/to/guokong_combine"
|
|
283
|
+
|
|
284
|
+
# Optional satellite basemap key. Leave commented unless you have a real key.
|
|
285
|
+
# [tianditu]
|
|
286
|
+
# api_key = "your-real-key"
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
You can also point to a different config file:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
export RSPLOT_CONFIG="/path/to/rsplot.toml"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Environment variables override TOML values:
|
|
262
296
|
|
|
263
297
|
```bash
|
|
264
298
|
export TIANDITU_API_KEY="your-key"
|
|
299
|
+
export RSPLOT_GEOJSON_DIR="/path/to/geojson"
|
|
300
|
+
export RSPLOT_FONT_DIR="/path/to/fonts"
|
|
301
|
+
export RSPLOT_DATA_DIR_NO2="/path/to/NO2"
|
|
302
|
+
export RSPLOT_DATA_DIR_O3="/path/to/O3"
|
|
303
|
+
export RSPLOT_DATA_DIR_O3PR="/path/to/O3PR"
|
|
304
|
+
export RSPLOT_DATA_DIR_HCHO="/path/to/HCHO"
|
|
305
|
+
export RSPLOT_DATA_DIR_GUOKONG="/path/to/guokong_combine"
|
|
306
|
+
export RSPLOT_HCHO_CMAP_FILE="/path/to/256x3_rgb_table.txt"
|
|
265
307
|
```
|
|
266
308
|
|
|
309
|
+
Command-line directory options, such as `--data-dir`, take precedence for the
|
|
310
|
+
command being run. If no TianDiTu API key is configured, use `--basemap none`
|
|
311
|
+
to avoid requesting satellite tiles.
|
|
312
|
+
|
|
267
313
|
## Usage
|
|
268
314
|
|
|
269
|
-
The examples below assume
|
|
270
|
-
environment with equivalent data paths configured.
|
|
315
|
+
The examples below assume data paths are configured and readable.
|
|
271
316
|
|
|
272
317
|
```bash
|
|
273
318
|
# Show available commands and options.
|
|
@@ -296,6 +341,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
296
341
|
|
|
297
342
|
# Plot an FNR regime map with an explicit date range.
|
|
298
343
|
rsplot fnr 长三角 20260101-20260130 -o fnr_yrd_202601.png
|
|
344
|
+
|
|
345
|
+
# Override FNR regime thresholds for agent-driven sensitivity tests.
|
|
346
|
+
rsplot fnr 合肥 20260114 --voc-threshold 0.8 --nox-threshold 1.8 --display-max 5 -o fnr_hefei_sensitivity.png
|
|
299
347
|
```
|
|
300
348
|
|
|
301
349
|
When `-o/--output` is provided, `rsplot` writes the image to that path and also
|
|
@@ -31,16 +31,61 @@ uv run rsplot --help
|
|
|
31
31
|
|
|
32
32
|
## Configuration
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
`rsplot` does not ship site-specific data paths. Configure local satellite,
|
|
35
|
+
station, boundary, and font paths with a TOML file:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
mkdir -p ~/.config/rsplot
|
|
39
|
+
cp config.example.toml ~/.config/rsplot/config.toml
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then edit `~/.config/rsplot/config.toml`:
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
[paths]
|
|
46
|
+
geojson_dir = "/path/to/geojson"
|
|
47
|
+
font_dir = "/path/to/fonts"
|
|
48
|
+
# hcho_cmap_file = "/path/to/256x3_rgb_table.txt"
|
|
49
|
+
|
|
50
|
+
[paths.data_dirs]
|
|
51
|
+
no2 = "/path/to/NO2"
|
|
52
|
+
o3 = "/path/to/O3"
|
|
53
|
+
o3pr = "/path/to/O3PR"
|
|
54
|
+
hcho = "/path/to/HCHO"
|
|
55
|
+
guokong = "/path/to/guokong_combine"
|
|
56
|
+
|
|
57
|
+
# Optional satellite basemap key. Leave commented unless you have a real key.
|
|
58
|
+
# [tianditu]
|
|
59
|
+
# api_key = "your-real-key"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
You can also point to a different config file:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export RSPLOT_CONFIG="/path/to/rsplot.toml"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Environment variables override TOML values:
|
|
35
69
|
|
|
36
70
|
```bash
|
|
37
71
|
export TIANDITU_API_KEY="your-key"
|
|
72
|
+
export RSPLOT_GEOJSON_DIR="/path/to/geojson"
|
|
73
|
+
export RSPLOT_FONT_DIR="/path/to/fonts"
|
|
74
|
+
export RSPLOT_DATA_DIR_NO2="/path/to/NO2"
|
|
75
|
+
export RSPLOT_DATA_DIR_O3="/path/to/O3"
|
|
76
|
+
export RSPLOT_DATA_DIR_O3PR="/path/to/O3PR"
|
|
77
|
+
export RSPLOT_DATA_DIR_HCHO="/path/to/HCHO"
|
|
78
|
+
export RSPLOT_DATA_DIR_GUOKONG="/path/to/guokong_combine"
|
|
79
|
+
export RSPLOT_HCHO_CMAP_FILE="/path/to/256x3_rgb_table.txt"
|
|
38
80
|
```
|
|
39
81
|
|
|
82
|
+
Command-line directory options, such as `--data-dir`, take precedence for the
|
|
83
|
+
command being run. If no TianDiTu API key is configured, use `--basemap none`
|
|
84
|
+
to avoid requesting satellite tiles.
|
|
85
|
+
|
|
40
86
|
## Usage
|
|
41
87
|
|
|
42
|
-
The examples below assume
|
|
43
|
-
environment with equivalent data paths configured.
|
|
88
|
+
The examples below assume data paths are configured and readable.
|
|
44
89
|
|
|
45
90
|
```bash
|
|
46
91
|
# Show available commands and options.
|
|
@@ -69,6 +114,9 @@ rsplot fnr 合肥 20260114 -o fnr_hefei.png
|
|
|
69
114
|
|
|
70
115
|
# Plot an FNR regime map with an explicit date range.
|
|
71
116
|
rsplot fnr 长三角 20260101-20260130 -o fnr_yrd_202601.png
|
|
117
|
+
|
|
118
|
+
# Override FNR regime thresholds for agent-driven sensitivity tests.
|
|
119
|
+
rsplot fnr 合肥 20260114 --voc-threshold 0.8 --nox-threshold 1.8 --display-max 5 -o fnr_hefei_sensitivity.png
|
|
72
120
|
```
|
|
73
121
|
|
|
74
122
|
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
|
|
|
@@ -414,6 +413,7 @@ def raster(
|
|
|
414
413
|
vmax=use_vmax,
|
|
415
414
|
basemap=use_basemap,
|
|
416
415
|
tianditu_key=cfg.tianditu_key,
|
|
416
|
+
hcho_cmap_file=cfg.hcho_cmap_file,
|
|
417
417
|
title=title,
|
|
418
418
|
output=output,
|
|
419
419
|
colorbar_label=colorbar_label,
|
|
@@ -533,17 +533,17 @@ def station(
|
|
|
533
533
|
if resolved_dt != datetime_str:
|
|
534
534
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
535
535
|
console.print(
|
|
536
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
537
|
-
f"(共 {data.n_stations} 站)"
|
|
536
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
538
537
|
)
|
|
539
538
|
|
|
540
539
|
# --- 5. Exceedance threshold ---
|
|
541
540
|
threshold = get_exceedance_threshold(var)
|
|
542
541
|
if threshold is not None:
|
|
543
|
-
n_exceed = int(
|
|
542
|
+
n_exceed = int(
|
|
543
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
544
|
+
)
|
|
544
545
|
console.print(
|
|
545
|
-
f" → 超标阈值: {threshold}, "
|
|
546
|
-
f"超标站点: [red]{n_exceed}[/red]"
|
|
546
|
+
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
547
547
|
)
|
|
548
548
|
|
|
549
549
|
# --- 6. Plot ---
|
|
@@ -590,7 +590,7 @@ def station(
|
|
|
590
590
|
datetime_str=resolved_dt,
|
|
591
591
|
threshold=threshold,
|
|
592
592
|
unit=unit,
|
|
593
|
-
cities_gdf=_load_city(cfg.
|
|
593
|
+
cities_gdf=_load_city(cfg.require_geojson_dir()),
|
|
594
594
|
output=output,
|
|
595
595
|
)
|
|
596
596
|
emit_result(result, output)
|
|
@@ -662,8 +662,12 @@ def overlay(
|
|
|
662
662
|
"-b",
|
|
663
663
|
help="底图控制: satellite / none (覆盖级别默认)",
|
|
664
664
|
),
|
|
665
|
-
vmin: float | None = typer.Option(
|
|
666
|
-
|
|
665
|
+
vmin: float | None = typer.Option(
|
|
666
|
+
None, "--vmin", help="卫星 colorbar 最小值"
|
|
667
|
+
),
|
|
668
|
+
vmax: float | None = typer.Option(
|
|
669
|
+
None, "--vmax", help="卫星 colorbar 最大值"
|
|
670
|
+
),
|
|
667
671
|
cmap: str | None = typer.Option(None, "--cmap", help="卫星 colormap 名称"),
|
|
668
672
|
smooth: float | None = typer.Option(
|
|
669
673
|
None, "--smooth", help="高斯平滑 sigma"
|
|
@@ -771,7 +775,9 @@ def overlay(
|
|
|
771
775
|
info.extent[2] - buf,
|
|
772
776
|
info.extent[3] + buf,
|
|
773
777
|
)
|
|
774
|
-
console.print(
|
|
778
|
+
console.print(
|
|
779
|
+
f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})"
|
|
780
|
+
)
|
|
775
781
|
console.print(f" 日期: {date}, QA > {use_qa}")
|
|
776
782
|
swath = reader.read(use_raster_dir, date, read_extent, qa_threshold=use_qa)
|
|
777
783
|
console.print(
|
|
@@ -826,23 +832,22 @@ def overlay(
|
|
|
826
832
|
if resolved_dt != datetime_str:
|
|
827
833
|
console.print(f" → 自动选择最新时次: [cyan]{resolved_dt}[/cyan]")
|
|
828
834
|
console.print(
|
|
829
|
-
f" → [green]{data.n_valid}[/green] 有效站点 "
|
|
830
|
-
f"(共 {data.n_stations} 站)"
|
|
835
|
+
f" → [green]{data.n_valid}[/green] 有效站点 (共 {data.n_stations} 站)"
|
|
831
836
|
)
|
|
832
837
|
|
|
833
838
|
# --- 10. Exceedance threshold ---
|
|
834
839
|
threshold = get_exceedance_threshold(var)
|
|
835
840
|
if threshold is not None:
|
|
836
|
-
n_exceed = int(
|
|
841
|
+
n_exceed = int(
|
|
842
|
+
np.sum(data.values[np.isfinite(data.values)] > threshold)
|
|
843
|
+
)
|
|
837
844
|
console.print(
|
|
838
845
|
f" → 超标阈值: {threshold}, 超标站点: [red]{n_exceed}[/red]"
|
|
839
846
|
)
|
|
840
847
|
|
|
841
848
|
# --- 11. Plot ---
|
|
842
849
|
if output is None:
|
|
843
|
-
output =
|
|
844
|
-
f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
845
|
-
)
|
|
850
|
+
output = f"/tmp/rsplot_overlay_{info.name}_{product}_{resolved_dt}.png"
|
|
846
851
|
|
|
847
852
|
unit = STATION_VAR_META[var][0]
|
|
848
853
|
station_label = f"{var} ({unit})" if unit else var
|
|
@@ -863,6 +868,7 @@ def overlay(
|
|
|
863
868
|
dpi=use_dpi,
|
|
864
869
|
basemap=use_basemap,
|
|
865
870
|
tianditu_key=cfg.tianditu_key,
|
|
871
|
+
hcho_cmap_file=cfg.hcho_cmap_file,
|
|
866
872
|
title=title,
|
|
867
873
|
output=output,
|
|
868
874
|
)
|
|
@@ -891,7 +897,7 @@ def overlay(
|
|
|
891
897
|
datetime_str=resolved_dt,
|
|
892
898
|
threshold=threshold,
|
|
893
899
|
unit=unit,
|
|
894
|
-
cities_gdf=_load_city(cfg.
|
|
900
|
+
cities_gdf=_load_city(cfg.require_geojson_dir()),
|
|
895
901
|
output=output,
|
|
896
902
|
)
|
|
897
903
|
emit_result(result, output)
|
|
@@ -1047,9 +1053,7 @@ def recent(
|
|
|
1047
1053
|
data_dir if data_dir is not None else cfg.get_data_dir("guokong")
|
|
1048
1054
|
)
|
|
1049
1055
|
unit = STATION_VAR_META[var][0]
|
|
1050
|
-
console.print(
|
|
1051
|
-
f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})"
|
|
1052
|
-
)
|
|
1056
|
+
console.print(f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})")
|
|
1053
1057
|
daily = summarize_recent_station(
|
|
1054
1058
|
region=info,
|
|
1055
1059
|
data_dir=use_data_dir,
|
|
@@ -1104,6 +1108,21 @@ def fnr(
|
|
|
1104
1108
|
help="最小平均 NO2 柱浓度阈值 (x10^15 molec/cm2);"
|
|
1105
1109
|
"低于此值的像元 FNR 不可信,输出为 NaN",
|
|
1106
1110
|
),
|
|
1111
|
+
voc_threshold: float = typer.Option(
|
|
1112
|
+
1.0,
|
|
1113
|
+
"--voc-threshold",
|
|
1114
|
+
help="VOC-limited 分级阈值:FNR 小于该值判为 VOC-limited",
|
|
1115
|
+
),
|
|
1116
|
+
nox_threshold: float = typer.Option(
|
|
1117
|
+
2.0,
|
|
1118
|
+
"--nox-threshold",
|
|
1119
|
+
help="NOx-limited 分级阈值:FNR 大于该值判为 NOx-limited",
|
|
1120
|
+
),
|
|
1121
|
+
display_max: float = typer.Option(
|
|
1122
|
+
6.0,
|
|
1123
|
+
"--display-max",
|
|
1124
|
+
help="FNR 图例显示上限,必须大于 --nox-threshold",
|
|
1125
|
+
),
|
|
1107
1126
|
# --- overrides ---
|
|
1108
1127
|
level: str | None = typer.Option(
|
|
1109
1128
|
None,
|
|
@@ -1155,7 +1174,11 @@ def fnr(
|
|
|
1155
1174
|
rsplot fnr 安徽 20260131 --days 30 (30日窗口)
|
|
1156
1175
|
rsplot fnr 长三角 20260101-20260130 (显式范围)
|
|
1157
1176
|
"""
|
|
1158
|
-
from rsplot.fnr import
|
|
1177
|
+
from rsplot.fnr import (
|
|
1178
|
+
compute_fnr,
|
|
1179
|
+
parse_date_window,
|
|
1180
|
+
validate_fnr_thresholds,
|
|
1181
|
+
)
|
|
1159
1182
|
from rsplot.geo.boundaries import resolve_region
|
|
1160
1183
|
from rsplot.plotting.fnr import plot_fnr
|
|
1161
1184
|
from rsplot.plotting.styles import setup_fonts
|
|
@@ -1179,11 +1202,27 @@ def fnr(
|
|
|
1179
1202
|
f"[red]--n-min ({use_n_min}) 大于窗口天数 ({window_n})[/red]"
|
|
1180
1203
|
)
|
|
1181
1204
|
raise typer.Exit(1)
|
|
1205
|
+
try:
|
|
1206
|
+
use_voc_threshold, use_nox_threshold, use_display_max = (
|
|
1207
|
+
validate_fnr_thresholds(
|
|
1208
|
+
voc_threshold=voc_threshold,
|
|
1209
|
+
nox_threshold=nox_threshold,
|
|
1210
|
+
display_max=display_max,
|
|
1211
|
+
)
|
|
1212
|
+
)
|
|
1213
|
+
except ValueError as e:
|
|
1214
|
+
console.print(f"[red]FNR 阈值错误: {e}[/red]")
|
|
1215
|
+
raise typer.Exit(1) from e
|
|
1182
1216
|
|
|
1183
1217
|
console.print(
|
|
1184
1218
|
f"[bold]时间窗口:[/bold] {dates[0]} ~ {dates[-1]} "
|
|
1185
1219
|
f"({window_n} 天,每像元至少 {use_n_min} 天有效)"
|
|
1186
1220
|
)
|
|
1221
|
+
console.print(
|
|
1222
|
+
f"[bold]FNR 分级:[/bold] VOC<{use_voc_threshold:g}, "
|
|
1223
|
+
f"transition {use_voc_threshold:g}~{use_nox_threshold:g}, "
|
|
1224
|
+
f"NOx>{use_nox_threshold:g}"
|
|
1225
|
+
)
|
|
1187
1226
|
|
|
1188
1227
|
# --- 3. Resolve region ---
|
|
1189
1228
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
@@ -1209,14 +1248,16 @@ def fnr(
|
|
|
1209
1248
|
|
|
1210
1249
|
# --- 5. Resolve data dirs ---
|
|
1211
1250
|
use_no2_dir = (
|
|
1212
|
-
raster_dir_no2
|
|
1251
|
+
raster_dir_no2
|
|
1252
|
+
if raster_dir_no2 is not None
|
|
1253
|
+
else cfg.get_data_dir("no2")
|
|
1213
1254
|
)
|
|
1214
1255
|
use_hcho_dir = (
|
|
1215
1256
|
raster_dir_hcho
|
|
1216
1257
|
if raster_dir_hcho is not None
|
|
1217
1258
|
else cfg.get_data_dir("hcho")
|
|
1218
1259
|
)
|
|
1219
|
-
console.print(
|
|
1260
|
+
console.print("\n[bold]读取数据:[/bold]")
|
|
1220
1261
|
console.print(f" NO2 : {use_no2_dir}")
|
|
1221
1262
|
console.print(f" HCHO: {use_hcho_dir}")
|
|
1222
1263
|
|
|
@@ -1239,12 +1280,10 @@ def fnr(
|
|
|
1239
1280
|
n_found_no2 = len(fnr_out.dates_found_no2)
|
|
1240
1281
|
n_found_hcho = len(fnr_out.dates_found_hcho)
|
|
1241
1282
|
console.print(
|
|
1242
|
-
f" → NO2 有效天数 {n_found_no2}/{window_n} "
|
|
1243
|
-
f"(失败 {n_fail_no2})"
|
|
1283
|
+
f" → NO2 有效天数 {n_found_no2}/{window_n} (失败 {n_fail_no2})"
|
|
1244
1284
|
)
|
|
1245
1285
|
console.print(
|
|
1246
|
-
f" → HCHO 有效天数 {n_found_hcho}/{window_n} "
|
|
1247
|
-
f"(失败 {n_fail_hcho})"
|
|
1286
|
+
f" → HCHO 有效天数 {n_found_hcho}/{window_n} (失败 {n_fail_hcho})"
|
|
1248
1287
|
)
|
|
1249
1288
|
if use_n_min > min(n_found_no2, n_found_hcho):
|
|
1250
1289
|
console.print(
|
|
@@ -1270,7 +1309,12 @@ def fnr(
|
|
|
1270
1309
|
)
|
|
1271
1310
|
|
|
1272
1311
|
from rsplot.fnr import regime_percentages
|
|
1273
|
-
|
|
1312
|
+
|
|
1313
|
+
pct = regime_percentages(
|
|
1314
|
+
fnr_out.fnr,
|
|
1315
|
+
voc_threshold=use_voc_threshold,
|
|
1316
|
+
nox_threshold=use_nox_threshold,
|
|
1317
|
+
)
|
|
1274
1318
|
console.print(
|
|
1275
1319
|
f" → Regime: [red]VOC-limited {pct['voc_limited']}%[/red] | "
|
|
1276
1320
|
f"[yellow]transition {pct['transition']}%[/yellow] | "
|
|
@@ -1279,9 +1323,7 @@ def fnr(
|
|
|
1279
1323
|
|
|
1280
1324
|
# --- 7. Plot ---
|
|
1281
1325
|
if output is None:
|
|
1282
|
-
output =
|
|
1283
|
-
f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1284
|
-
)
|
|
1326
|
+
output = f"/tmp/rsplot_fnr_{info.name}_{dates[0]}_{dates[-1]}.png"
|
|
1285
1327
|
|
|
1286
1328
|
subtitle = (
|
|
1287
1329
|
f"{dates[0]}~{dates[-1]} · N_days={window_n} "
|
|
@@ -1300,6 +1342,9 @@ def fnr(
|
|
|
1300
1342
|
title=title,
|
|
1301
1343
|
output=output,
|
|
1302
1344
|
subtitle=subtitle,
|
|
1345
|
+
voc_threshold=use_voc_threshold,
|
|
1346
|
+
nox_threshold=use_nox_threshold,
|
|
1347
|
+
display_max=use_display_max,
|
|
1303
1348
|
)
|
|
1304
1349
|
console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
|
|
1305
1350
|
|
|
@@ -1309,6 +1354,9 @@ def fnr(
|
|
|
1309
1354
|
fnr_out=fnr_out,
|
|
1310
1355
|
res=use_res,
|
|
1311
1356
|
output=output,
|
|
1357
|
+
voc_threshold=use_voc_threshold,
|
|
1358
|
+
nox_threshold=use_nox_threshold,
|
|
1359
|
+
display_max=use_display_max,
|
|
1312
1360
|
)
|
|
1313
1361
|
emit_result(result, output)
|
|
1314
1362
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Configuration management for rsplot.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Runtime paths are intentionally configured outside the package. The loader
|
|
4
|
+
merges safe built-in defaults, TOML config files, environment variables, and
|
|
5
|
+
finally CLI overrides handled by command functions.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from __future__ import annotations
|
|
@@ -19,21 +20,20 @@ else:
|
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
# ---------------------------------------------------------------------------
|
|
22
|
-
#
|
|
23
|
+
# Safe built-in path defaults
|
|
23
24
|
# ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
DEFAULT_GEOJSON_DIR = "/satellite/d4/yaoyhu/tiandi"
|
|
32
|
-
DEFAULT_FONT_DIR = "/satellite/d4/yaoyhu/fonts"
|
|
25
|
+
KNOWN_DATA_PRODUCTS = ("no2", "o3", "o3pr", "hcho", "guokong")
|
|
26
|
+
|
|
27
|
+
DEFAULT_DATA_DIRS: dict[str, str] = {}
|
|
28
|
+
DEFAULT_GEOJSON_DIR = None
|
|
29
|
+
DEFAULT_FONT_DIR = None
|
|
30
|
+
DEFAULT_HCHO_CMAP_FILE = None
|
|
33
31
|
DEFAULT_TIANDITU_KEY = None
|
|
34
32
|
|
|
35
33
|
|
|
36
34
|
CONFIG_PATH = Path.home() / ".config" / "rsplot" / "config.toml"
|
|
35
|
+
CONFIG_ENV_VAR = "RSPLOT_CONFIG"
|
|
36
|
+
HCHO_CMAP_FILE_ENV = "RSPLOT_HCHO_CMAP_FILE"
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
# ---------------------------------------------------------------------------
|
|
@@ -146,15 +146,17 @@ DEFAULT_KEY_REGIONS: dict[str, list[str]] = {
|
|
|
146
146
|
# ---------------------------------------------------------------------------
|
|
147
147
|
@dataclass
|
|
148
148
|
class AppConfig:
|
|
149
|
-
"""Merged configuration
|
|
149
|
+
"""Merged configuration from files and environment variables."""
|
|
150
150
|
|
|
151
151
|
# paths (per-product data directories)
|
|
152
152
|
data_dirs: dict[str, str] = field(
|
|
153
153
|
default_factory=lambda: dict(DEFAULT_DATA_DIRS)
|
|
154
154
|
)
|
|
155
|
-
geojson_dir: str = DEFAULT_GEOJSON_DIR
|
|
156
|
-
font_dir: str = DEFAULT_FONT_DIR
|
|
155
|
+
geojson_dir: str | None = DEFAULT_GEOJSON_DIR
|
|
156
|
+
font_dir: str | None = DEFAULT_FONT_DIR
|
|
157
|
+
hcho_cmap_file: str | None = DEFAULT_HCHO_CMAP_FILE
|
|
157
158
|
tianditu_key: str | None = DEFAULT_TIANDITU_KEY
|
|
159
|
+
config_files: list[str] = field(default_factory=list)
|
|
158
160
|
|
|
159
161
|
# plotting defaults
|
|
160
162
|
dpi: int = 300
|
|
@@ -167,43 +169,82 @@ class AppConfig:
|
|
|
167
169
|
|
|
168
170
|
@classmethod
|
|
169
171
|
def load(cls) -> AppConfig:
|
|
170
|
-
"""Load config from TOML
|
|
172
|
+
"""Load config from TOML files and environment variables.
|
|
173
|
+
|
|
174
|
+
Merge order:
|
|
175
|
+
1. safe built-in defaults
|
|
176
|
+
2. ~/.config/rsplot/config.toml, if present
|
|
177
|
+
3. RSPLOT_CONFIG, if set
|
|
178
|
+
4. environment variable path/API-key overrides
|
|
179
|
+
"""
|
|
171
180
|
cfg = cls()
|
|
172
|
-
|
|
173
|
-
with open(
|
|
181
|
+
for path in _config_paths():
|
|
182
|
+
with open(path, "rb") as f:
|
|
174
183
|
data = tomllib.load(f)
|
|
175
184
|
cfg._apply_toml(data)
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
cfg.config_files.append(str(path))
|
|
186
|
+
|
|
187
|
+
cfg._apply_env()
|
|
188
|
+
return cfg
|
|
189
|
+
|
|
190
|
+
def _apply_env(self) -> None:
|
|
191
|
+
"""Apply environment variable overrides."""
|
|
192
|
+
products = set(KNOWN_DATA_PRODUCTS) | set(self.data_dirs)
|
|
193
|
+
for product in sorted(products):
|
|
178
194
|
env_key = f"RSPLOT_DATA_DIR_{product.upper()}"
|
|
179
195
|
if v := os.environ.get(env_key):
|
|
180
|
-
|
|
196
|
+
self.data_dirs[product] = _normalize_path(v)
|
|
181
197
|
if v := os.environ.get("RSPLOT_GEOJSON_DIR"):
|
|
182
|
-
|
|
198
|
+
self.geojson_dir = _normalize_path(v)
|
|
199
|
+
if v := os.environ.get("RSPLOT_FONT_DIR"):
|
|
200
|
+
self.font_dir = _normalize_path(v)
|
|
201
|
+
if v := os.environ.get(HCHO_CMAP_FILE_ENV):
|
|
202
|
+
self.hcho_cmap_file = _normalize_path(v)
|
|
183
203
|
if v := os.environ.get("TIANDITU_API_KEY"):
|
|
184
|
-
|
|
185
|
-
return cfg
|
|
204
|
+
self.tianditu_key = v
|
|
186
205
|
|
|
187
206
|
def get_data_dir(self, product: str) -> str:
|
|
188
207
|
"""Return data directory for a product, or raise if not configured."""
|
|
189
208
|
key = product.lower()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
209
|
+
dir_path = self.data_dirs.get(key)
|
|
210
|
+
if dir_path:
|
|
211
|
+
return dir_path
|
|
212
|
+
|
|
213
|
+
configured = (
|
|
214
|
+
", ".join(sorted(k for k, v in self.data_dirs.items() if v))
|
|
215
|
+
or "none"
|
|
216
|
+
)
|
|
193
217
|
raise ValueError(
|
|
194
218
|
f"No data directory configured for product '{product}'. "
|
|
195
|
-
f"
|
|
219
|
+
f"Set [paths.data_dirs].{key} in {CONFIG_PATH}, set "
|
|
220
|
+
f"RSPLOT_DATA_DIR_{key.upper()}, point {CONFIG_ENV_VAR} to a "
|
|
221
|
+
"TOML config file, or pass the command-specific data directory "
|
|
222
|
+
f"option. Known products: {', '.join(KNOWN_DATA_PRODUCTS)}. "
|
|
223
|
+
f"Configured products: {configured}."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def require_geojson_dir(self) -> str:
|
|
227
|
+
"""Return the boundary GeoJSON directory, or raise with guidance."""
|
|
228
|
+
if self.geojson_dir:
|
|
229
|
+
return self.geojson_dir
|
|
230
|
+
raise ValueError(
|
|
231
|
+
"GeoJSON boundary directory is not configured. Set "
|
|
232
|
+
f"[paths].geojson_dir in {CONFIG_PATH}, set RSPLOT_GEOJSON_DIR, "
|
|
233
|
+
f"or point {CONFIG_ENV_VAR} to a TOML config file."
|
|
196
234
|
)
|
|
197
235
|
|
|
198
236
|
def _apply_toml(self, data: dict[str, Any]) -> None:
|
|
199
237
|
paths = data.get("paths", {})
|
|
200
238
|
data_dirs = paths.get("data_dirs", {})
|
|
201
239
|
for product, dir_path in data_dirs.items():
|
|
202
|
-
|
|
240
|
+
if dir_path:
|
|
241
|
+
self.data_dirs[product.lower()] = _normalize_path(dir_path)
|
|
203
242
|
if v := paths.get("geojson_dir"):
|
|
204
|
-
self.geojson_dir = v
|
|
243
|
+
self.geojson_dir = _normalize_path(v)
|
|
205
244
|
if v := paths.get("font_dir"):
|
|
206
|
-
self.font_dir = v
|
|
245
|
+
self.font_dir = _normalize_path(v)
|
|
246
|
+
if v := paths.get("hcho_cmap_file"):
|
|
247
|
+
self.hcho_cmap_file = _normalize_path(v)
|
|
207
248
|
|
|
208
249
|
td = data.get("tianditu", {})
|
|
209
250
|
if v := td.get("api_key"):
|
|
@@ -220,6 +261,33 @@ class AppConfig:
|
|
|
220
261
|
self.key_regions[name] = provinces
|
|
221
262
|
|
|
222
263
|
|
|
264
|
+
def _config_paths() -> list[Path]:
|
|
265
|
+
"""Return config files to load in priority order."""
|
|
266
|
+
paths: list[Path] = []
|
|
267
|
+
if CONFIG_PATH.exists():
|
|
268
|
+
paths.append(CONFIG_PATH)
|
|
269
|
+
|
|
270
|
+
if raw := os.environ.get(CONFIG_ENV_VAR):
|
|
271
|
+
path = Path(_normalize_path(raw))
|
|
272
|
+
if not path.exists():
|
|
273
|
+
raise FileNotFoundError(
|
|
274
|
+
f"{CONFIG_ENV_VAR} points to a missing config file: {path}"
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
already_loaded = path.resolve() in {p.resolve() for p in paths}
|
|
278
|
+
except OSError:
|
|
279
|
+
already_loaded = path in paths
|
|
280
|
+
if not already_loaded:
|
|
281
|
+
paths.append(path)
|
|
282
|
+
|
|
283
|
+
return paths
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _normalize_path(value: Any) -> str:
|
|
287
|
+
"""Expand ~ and environment variables in configured path values."""
|
|
288
|
+
return os.path.expanduser(os.path.expandvars(str(value)))
|
|
289
|
+
|
|
290
|
+
|
|
223
291
|
# ---------------------------------------------------------------------------
|
|
224
292
|
# GB 3095-2026 Air Quality Limits (transitional phase, grade II)
|
|
225
293
|
# ---------------------------------------------------------------------------
|
|
@@ -241,7 +309,7 @@ AQ_LIMITS_2026: dict[str, dict[str, float]] = {
|
|
|
241
309
|
# ---------------------------------------------------------------------------
|
|
242
310
|
AQI_COLORS: list[tuple[int, str]] = [
|
|
243
311
|
# (upper bound, hex color)
|
|
244
|
-
(50, "#00e400"),
|
|
312
|
+
(50, "#00e400"), # 优 / green
|
|
245
313
|
(100, "#ffff00"), # 良 / yellow
|
|
246
314
|
(150, "#ff7e00"), # 轻度污染 / orange
|
|
247
315
|
(200, "#ff0000"), # 中度污染 / red
|