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.
Files changed (31) hide show
  1. {rsplot-0.2.3 → rsplot-0.2.4}/PKG-INFO +4 -1
  2. {rsplot-0.2.3 → rsplot-0.2.4}/README.md +3 -0
  3. {rsplot-0.2.3 → rsplot-0.2.4}/pyproject.toml +1 -1
  4. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/cli.py +80 -34
  5. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/fnr.py +56 -6
  6. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/fnr.py +34 -12
  7. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/results.py +33 -14
  8. {rsplot-0.2.3 → rsplot-0.2.4}/.gitignore +0 -0
  9. {rsplot-0.2.3 → rsplot-0.2.4}/LICENSE +0 -0
  10. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/__init__.py +0 -0
  11. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/__main__.py +0 -0
  12. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/config.py +0 -0
  13. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/__init__.py +0 -0
  14. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/boundaries.py +0 -0
  15. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/geo/gridding.py +0 -0
  16. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/__init__.py +0 -0
  17. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/colormaps.py +0 -0
  18. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/overlay.py +0 -0
  19. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/raster.py +0 -0
  20. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/station.py +0 -0
  21. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/plotting/styles.py +0 -0
  22. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/__init__.py +0 -0
  23. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/base.py +0 -0
  24. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/guokong.py +0 -0
  25. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_hcho.py +0 -0
  26. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_no2.py +0 -0
  27. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3.py +0 -0
  28. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/readers/tropomi_o3pr.py +0 -0
  29. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/recent.py +0 -0
  30. {rsplot-0.2.3 → rsplot-0.2.4}/src/rsplot/tiles/__init__.py +0 -0
  31. {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
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.3"
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(
@@ -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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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(None, "--vmin", help="卫星 colorbar 最小值"),
666
- 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
+ ),
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(f"\n[bold]读取卫星数据:[/bold] {use_raster_dir} (产品: {product})")
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(np.sum(data.values[np.isfinite(data.values)] > threshold))
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 compute_fnr, parse_date_window
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 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")
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
- 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
+ )
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(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
  )
@@ -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
File without changes