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.
Files changed (33) hide show
  1. {rsplot-0.2.3 → rsplot-0.2.5}/PKG-INFO +52 -4
  2. {rsplot-0.2.3 → rsplot-0.2.5}/README.md +51 -3
  3. {rsplot-0.2.3 → rsplot-0.2.5}/pyproject.toml +1 -1
  4. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/cli.py +85 -37
  5. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/config.py +100 -32
  6. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/fnr.py +56 -6
  7. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/boundaries.py +6 -6
  8. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/colormaps.py +9 -10
  9. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/fnr.py +34 -12
  10. rsplot-0.2.5/src/rsplot/plotting/meteo.py +422 -0
  11. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/overlay.py +23 -8
  12. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/raster.py +2 -1
  13. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/styles.py +9 -2
  14. rsplot-0.2.5/src/rsplot/readers/era5.py +477 -0
  15. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/guokong.py +7 -7
  16. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/results.py +33 -14
  17. {rsplot-0.2.3 → rsplot-0.2.5}/.gitignore +0 -0
  18. {rsplot-0.2.3 → rsplot-0.2.5}/LICENSE +0 -0
  19. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/__init__.py +0 -0
  20. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/__main__.py +0 -0
  21. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/__init__.py +0 -0
  22. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/geo/gridding.py +0 -0
  23. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/__init__.py +0 -0
  24. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/plotting/station.py +0 -0
  25. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/__init__.py +0 -0
  26. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/base.py +0 -0
  27. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_hcho.py +0 -0
  28. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_no2.py +0 -0
  29. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_o3.py +0 -0
  30. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/readers/tropomi_o3pr.py +0 -0
  31. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/recent.py +0 -0
  32. {rsplot-0.2.3 → rsplot-0.2.5}/src/rsplot/tiles/__init__.py +0 -0
  33. {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
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
- For satellite basemaps, set a TianDiTu API key:
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 the command is running on a USTC server or in an
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
- For satellite basemaps, set a TianDiTu API key:
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 the command is running on a USTC server or in an
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "CLI tool for plotting Remote Sensing data, designed for Agent."
5
5
  authors = [
6
6
  { name = "Yaoyao Hu", email = "yaoyhu@mail.ustc.edu.cn" },
@@ -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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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.geojson_dir),
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(None, "--vmin", help="卫星 colorbar 最小值"),
666
- vmax: float | None = typer.Option(None, "--vmax", help="卫星 colorbar 最大值"),
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(f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})")
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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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.geojson_dir),
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 compute_fnr, parse_date_window
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 if raster_dir_no2 is not None else cfg.get_data_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(f"\n[bold]读取数据:[/bold]")
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
- pct = regime_percentages(fnr_out.fnr)
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
- # TODO: Loads defaults from ~/.config/rsplot/config.toml if present,
4
- CLI arguments override everything.
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
- # Default paths
23
+ # Safe built-in path defaults
23
24
  # ---------------------------------------------------------------------------
24
- DEFAULT_DATA_DIRS: dict[str, str] = {
25
- "no2": "/satellite/exports2/yaoyhu/NO2",
26
- "o3": "/satellite/exports2/yaoyhu/O3",
27
- "o3pr": "/satellite/exports2/yaoyhu/O3PR",
28
- "hcho": "/satellite/exports2/yaoyhu/HCHO",
29
- "guokong": "/home/zzgsg/data/guokong_combine",
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 (file defaults + CLI overrides)."""
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 file, falling back to defaults."""
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
- if CONFIG_PATH.exists():
173
- with open(CONFIG_PATH, "rb") as f:
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
- # Environment variable overrides (per-product: RSPLOT_DATA_DIR_NO2, etc.)
177
- for product in list(cfg.data_dirs):
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
- cfg.data_dirs[product] = v
196
+ self.data_dirs[product] = _normalize_path(v)
181
197
  if v := os.environ.get("RSPLOT_GEOJSON_DIR"):
182
- cfg.geojson_dir = v
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
- cfg.tianditu_key = v
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
- if key in self.data_dirs:
191
- return self.data_dirs[key]
192
- available = ", ".join(self.data_dirs)
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"Available: {available}"
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
- self.data_dirs[product.lower()] = dir_path
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"), # 优 / green
312
+ (50, "#00e400"), # 优 / green
245
313
  (100, "#ffff00"), # 良 / yellow
246
314
  (150, "#ff7e00"), # 轻度污染 / orange
247
315
  (200, "#ff0000"), # 中度污染 / red