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.
Files changed (31) hide show
  1. {rsplot-0.2.2 → rsplot-0.2.4}/PKG-INFO +7 -1
  2. {rsplot-0.2.2 → rsplot-0.2.4}/README.md +6 -0
  3. {rsplot-0.2.2 → rsplot-0.2.4}/pyproject.toml +1 -1
  4. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/cli.py +124 -44
  5. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/config.py +1 -0
  6. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/fnr.py +56 -6
  7. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/fnr.py +34 -12
  8. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/__init__.py +35 -1
  9. rsplot-0.2.4/src/rsplot/readers/tropomi_o3pr.py +103 -0
  10. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/results.py +33 -14
  11. {rsplot-0.2.2 → rsplot-0.2.4}/.gitignore +0 -0
  12. {rsplot-0.2.2 → rsplot-0.2.4}/LICENSE +0 -0
  13. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/__init__.py +0 -0
  14. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/__main__.py +0 -0
  15. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/__init__.py +0 -0
  16. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/boundaries.py +0 -0
  17. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/geo/gridding.py +0 -0
  18. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/__init__.py +0 -0
  19. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/colormaps.py +0 -0
  20. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/overlay.py +0 -0
  21. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/raster.py +0 -0
  22. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/station.py +0 -0
  23. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/plotting/styles.py +0 -0
  24. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/base.py +0 -0
  25. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/guokong.py +0 -0
  26. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_hcho.py +0 -0
  27. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_no2.py +0 -0
  28. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3.py +0 -0
  29. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/recent.py +0 -0
  30. {rsplot-0.2.2 → rsplot-0.2.4}/src/rsplot/tiles/__init__.py +0 -0
  31. {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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.2"
3
+ version = "0.2.4"
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(
@@ -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=max(3, 天数//2), "
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 = smooth if smooth is not None else params.smooth_sigma
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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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(None, "--vmin", help="卫星 colorbar 最小值"),
652
- vmax: float | None = typer.Option(None, "--vmax", help="卫星 colorbar 最大值"),
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 = smooth if smooth is not None else params.smooth_sigma
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(f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})")
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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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
- use_res = res if res is not None else info.params.res
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 compute_fnr, parse_date_window
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 if raster_dir_no2 is not None else cfg.get_data_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
- pct = regime_percentages(fnr_out.fnr)
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(fnr: np.ndarray) -> np.ndarray:
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 < FNR_VOC_LIMIT)] = 0
369
- code[valid & (fnr >= FNR_VOC_LIMIT) & (fnr <= FNR_NOX_LIMIT)] = 1
370
- code[valid & (fnr > FNR_NOX_LIMIT)] = 2
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(fnr: np.ndarray) -> dict[str, float]:
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(fnr)
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 FNR_VOC_LIMIT / FNR_NOX_LIMIT thresholds so the regime readout
6
- is directly visible on the map.
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 FNR_DISPLAY_MAX, FNR_NOX_LIMIT, FNR_VOC_LIMIT
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() -> tuple[ListedColormap, BoundaryNorm]:
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, FNR_VOC_LIMIT, FNR_NOX_LIMIT, FNR_DISPLAY_MAX]
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
- FNR_VOC_LIMIT / 2,
164
- (FNR_VOC_LIMIT + FNR_NOX_LIMIT) / 2,
165
- (FNR_NOX_LIMIT + FNR_DISPLAY_MAX) / 2,
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(<{FNR_VOC_LIMIT:.0f})",
171
- f"Transition\n({FNR_VOC_LIMIT:.0f}-{FNR_NOX_LIMIT:.0f})",
172
- f"NOx-limited\n(>{FNR_NOX_LIMIT:.0f})",
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 < FNR_VOC_LIMIT:
495
+ if mean_fnr < voc_threshold:
493
496
  regime = "voc_limited"
494
- elif mean_fnr <= FNR_NOX_LIMIT:
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 < FNR_VOC_LIMIT).mean()), 1
509
+ 100 * float((finite < voc_threshold).mean()), 1
507
510
  ),
508
511
  "nox_limited_pct": round(
509
- 100 * float((finite > FNR_NOX_LIMIT).mean()), 1
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": FNR_VOC_LIMIT,
589
- "nox_threshold": FNR_NOX_LIMIT,
590
- "display_max": FNR_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(fnr),
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, fnr_out.LAT, fnr, sub_gdf
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