rsplot 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- rsplot/__main__.py +6 -0
- rsplot/cli.py +1100 -0
- rsplot/config.py +290 -0
- rsplot/fnr.py +384 -0
- rsplot/geo/__init__.py +0 -0
- rsplot/geo/boundaries.py +281 -0
- rsplot/geo/gridding.py +120 -0
- rsplot/plotting/__init__.py +1 -0
- rsplot/plotting/colormaps.py +67 -0
- rsplot/plotting/fnr.py +200 -0
- rsplot/plotting/overlay.py +241 -0
- rsplot/plotting/raster.py +222 -0
- rsplot/plotting/station.py +225 -0
- rsplot/plotting/styles.py +35 -0
- rsplot/readers/__init__.py +96 -0
- rsplot/readers/base.py +96 -0
- rsplot/readers/guokong.py +143 -0
- rsplot/readers/tropomi_hcho.py +126 -0
- rsplot/readers/tropomi_no2.py +102 -0
- rsplot/readers/tropomi_o3.py +104 -0
- rsplot/results.py +553 -0
- rsplot/tiles/__init__.py +1 -0
- rsplot/tiles/tianditu.py +32 -0
- rsplot-0.1.0.dist-info/METADATA +252 -0
- rsplot-0.1.0.dist-info/RECORD +29 -0
- rsplot-0.1.0.dist-info/WHEEL +4 -0
- rsplot-0.1.0.dist-info/entry_points.txt +2 -0
- rsplot-0.1.0.dist-info/licenses/LICENSE +201 -0
rsplot/config.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Configuration management for rsplot.
|
|
2
|
+
|
|
3
|
+
# TODO: Loads defaults from ~/.config/rsplot/config.toml if present,
|
|
4
|
+
CLI arguments override everything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
if sys.version_info >= (3, 11):
|
|
16
|
+
import tomllib
|
|
17
|
+
else:
|
|
18
|
+
import tomli as tomllib
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Default paths
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
DEFAULT_DATA_DIRS: dict[str, str] = {
|
|
25
|
+
"no2": "/satellite/exports2/yaoyhu/NO2",
|
|
26
|
+
"o3": "/satellite/exports2/yaoyhu/O3",
|
|
27
|
+
"hcho": "/satellite/exports2/yaoyhu/HCHO",
|
|
28
|
+
"guokong": "/home/zzgsg/data/guokong_combine",
|
|
29
|
+
}
|
|
30
|
+
DEFAULT_GEOJSON_DIR = "/satellite/d4/yaoyhu/tiandi"
|
|
31
|
+
DEFAULT_FONT_DIR = "/satellite/d4/yaoyhu/fonts"
|
|
32
|
+
DEFAULT_TIANDITU_KEY = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CONFIG_PATH = Path.home() / ".config" / "rsplot" / "config.toml"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Per-level default parameters
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
@dataclass
|
|
42
|
+
class LevelParams:
|
|
43
|
+
"""Plotting parameters derived from administrative level."""
|
|
44
|
+
|
|
45
|
+
res: float
|
|
46
|
+
basemap: bool
|
|
47
|
+
smooth_sigma: float | None
|
|
48
|
+
grid_step: float
|
|
49
|
+
pad: float
|
|
50
|
+
boundary_linewidth: float
|
|
51
|
+
label_fontsize: float
|
|
52
|
+
zoom: int # TianDiTu tile zoom level
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
LEVEL_DEFAULTS: dict[str, LevelParams] = {
|
|
56
|
+
"country": LevelParams(
|
|
57
|
+
res=0.1,
|
|
58
|
+
basemap=False,
|
|
59
|
+
smooth_sigma=None,
|
|
60
|
+
grid_step=5.0,
|
|
61
|
+
pad=0.5,
|
|
62
|
+
boundary_linewidth=0.5,
|
|
63
|
+
label_fontsize=6,
|
|
64
|
+
zoom=5,
|
|
65
|
+
),
|
|
66
|
+
"key_region": LevelParams(
|
|
67
|
+
res=0.05,
|
|
68
|
+
basemap=False,
|
|
69
|
+
smooth_sigma=None,
|
|
70
|
+
grid_step=2.0,
|
|
71
|
+
pad=0.2,
|
|
72
|
+
boundary_linewidth=1.0,
|
|
73
|
+
label_fontsize=7,
|
|
74
|
+
zoom=7,
|
|
75
|
+
),
|
|
76
|
+
"province": LevelParams(
|
|
77
|
+
res=0.05,
|
|
78
|
+
basemap=False,
|
|
79
|
+
smooth_sigma=None,
|
|
80
|
+
grid_step=1.0,
|
|
81
|
+
pad=0.15,
|
|
82
|
+
boundary_linewidth=0.8,
|
|
83
|
+
label_fontsize=7,
|
|
84
|
+
zoom=8,
|
|
85
|
+
),
|
|
86
|
+
"city": LevelParams(
|
|
87
|
+
res=0.03,
|
|
88
|
+
basemap=True,
|
|
89
|
+
smooth_sigma=1.5,
|
|
90
|
+
grid_step=0.5,
|
|
91
|
+
pad=0.15,
|
|
92
|
+
boundary_linewidth=1.5,
|
|
93
|
+
label_fontsize=8,
|
|
94
|
+
zoom=10,
|
|
95
|
+
),
|
|
96
|
+
"county": LevelParams(
|
|
97
|
+
res=0.01,
|
|
98
|
+
basemap=True,
|
|
99
|
+
smooth_sigma=1.5,
|
|
100
|
+
grid_step=0.2,
|
|
101
|
+
pad=0.05,
|
|
102
|
+
boundary_linewidth=1.5,
|
|
103
|
+
label_fontsize=9,
|
|
104
|
+
zoom=12,
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Preset key regions (province name lists)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
DEFAULT_KEY_REGIONS: dict[str, list[str]] = {
|
|
112
|
+
"YRD": ["上海市", "江苏省", "浙江省", "安徽省"],
|
|
113
|
+
"长三角": ["上海市", "江苏省", "浙江省", "安徽省"],
|
|
114
|
+
"BTH": ["北京市", "天津市", "河北省"],
|
|
115
|
+
"京津冀": ["北京市", "天津市", "河北省"],
|
|
116
|
+
"PRD": [
|
|
117
|
+
"广州市",
|
|
118
|
+
"深圳市",
|
|
119
|
+
"珠海市",
|
|
120
|
+
"东莞市",
|
|
121
|
+
"中山市",
|
|
122
|
+
"江门市",
|
|
123
|
+
"惠州市",
|
|
124
|
+
"肇庆市",
|
|
125
|
+
"佛山市",
|
|
126
|
+
],
|
|
127
|
+
"珠三角": [
|
|
128
|
+
"广州市",
|
|
129
|
+
"深圳市",
|
|
130
|
+
"珠海市",
|
|
131
|
+
"东莞市",
|
|
132
|
+
"中山市",
|
|
133
|
+
"江门市",
|
|
134
|
+
"惠州市",
|
|
135
|
+
"肇庆市",
|
|
136
|
+
"佛山市",
|
|
137
|
+
],
|
|
138
|
+
"SCB": ["四川省", "重庆市"],
|
|
139
|
+
"成渝": ["四川省", "重庆市"],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Global app config
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
@dataclass
|
|
147
|
+
class AppConfig:
|
|
148
|
+
"""Merged configuration (file defaults + CLI overrides)."""
|
|
149
|
+
|
|
150
|
+
# paths (per-product data directories)
|
|
151
|
+
data_dirs: dict[str, str] = field(
|
|
152
|
+
default_factory=lambda: dict(DEFAULT_DATA_DIRS)
|
|
153
|
+
)
|
|
154
|
+
geojson_dir: str = DEFAULT_GEOJSON_DIR
|
|
155
|
+
font_dir: str = DEFAULT_FONT_DIR
|
|
156
|
+
tianditu_key: str | None = DEFAULT_TIANDITU_KEY
|
|
157
|
+
|
|
158
|
+
# plotting defaults
|
|
159
|
+
dpi: int = 300
|
|
160
|
+
qa_threshold: float = 0.5
|
|
161
|
+
|
|
162
|
+
# key regions
|
|
163
|
+
key_regions: dict[str, list[str]] = field(
|
|
164
|
+
default_factory=lambda: dict(DEFAULT_KEY_REGIONS)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def load(cls) -> AppConfig:
|
|
169
|
+
"""Load config from TOML file, falling back to defaults."""
|
|
170
|
+
cfg = cls()
|
|
171
|
+
if CONFIG_PATH.exists():
|
|
172
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
173
|
+
data = tomllib.load(f)
|
|
174
|
+
cfg._apply_toml(data)
|
|
175
|
+
# Environment variable overrides (per-product: RSPLOT_DATA_DIR_NO2, etc.)
|
|
176
|
+
for product in list(cfg.data_dirs):
|
|
177
|
+
env_key = f"RSPLOT_DATA_DIR_{product.upper()}"
|
|
178
|
+
if v := os.environ.get(env_key):
|
|
179
|
+
cfg.data_dirs[product] = v
|
|
180
|
+
if v := os.environ.get("RSPLOT_GEOJSON_DIR"):
|
|
181
|
+
cfg.geojson_dir = v
|
|
182
|
+
if v := os.environ.get("TIANDITU_API_KEY"):
|
|
183
|
+
cfg.tianditu_key = v
|
|
184
|
+
return cfg
|
|
185
|
+
|
|
186
|
+
def get_data_dir(self, product: str) -> str:
|
|
187
|
+
"""Return data directory for a product, or raise if not configured."""
|
|
188
|
+
key = product.lower()
|
|
189
|
+
if key in self.data_dirs:
|
|
190
|
+
return self.data_dirs[key]
|
|
191
|
+
available = ", ".join(self.data_dirs)
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"No data directory configured for product '{product}'. "
|
|
194
|
+
f"Available: {available}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _apply_toml(self, data: dict[str, Any]) -> None:
|
|
198
|
+
paths = data.get("paths", {})
|
|
199
|
+
data_dirs = paths.get("data_dirs", {})
|
|
200
|
+
for product, dir_path in data_dirs.items():
|
|
201
|
+
self.data_dirs[product.lower()] = dir_path
|
|
202
|
+
if v := paths.get("geojson_dir"):
|
|
203
|
+
self.geojson_dir = v
|
|
204
|
+
if v := paths.get("font_dir"):
|
|
205
|
+
self.font_dir = v
|
|
206
|
+
|
|
207
|
+
td = data.get("tianditu", {})
|
|
208
|
+
if v := td.get("api_key"):
|
|
209
|
+
self.tianditu_key = v
|
|
210
|
+
|
|
211
|
+
defaults = data.get("defaults", {})
|
|
212
|
+
if v := defaults.get("dpi"):
|
|
213
|
+
self.dpi = int(v)
|
|
214
|
+
if v := defaults.get("qa_threshold"):
|
|
215
|
+
self.qa_threshold = float(v)
|
|
216
|
+
|
|
217
|
+
kr = data.get("key_regions", {})
|
|
218
|
+
for name, provinces in kr.items():
|
|
219
|
+
self.key_regions[name] = provinces
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# GB 3095-2026 Air Quality Limits (transitional phase, grade II)
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
AQ_LIMITS_2026: dict[str, dict[str, float]] = {
|
|
226
|
+
"SO2": {"annual": 60, "daily": 150, "hourly": 500},
|
|
227
|
+
"NO2": {"annual": 40, "daily": 80, "hourly": 200},
|
|
228
|
+
"CO": {"daily": 4, "hourly": 10},
|
|
229
|
+
"O3": {"8h": 160, "hourly": 200},
|
|
230
|
+
"PM10": {"annual": 60, "daily": 120},
|
|
231
|
+
"PM2_5": {"annual": 30, "daily": 60},
|
|
232
|
+
"TSP": {"annual": 200, "daily": 300},
|
|
233
|
+
"NOx": {"annual": 40, "daily": 70, "hourly": 250},
|
|
234
|
+
"Pb": {"annual": 0.5, "seasonal": 1.0},
|
|
235
|
+
"BaP": {"annual": 0.001, "daily": 0.0025},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# AQI Classification (official standard)
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
AQI_COLORS: list[tuple[int, str]] = [
|
|
242
|
+
# (upper bound, hex color)
|
|
243
|
+
(50, "#00e400"), # 优 / green
|
|
244
|
+
(100, "#ffff00"), # 良 / yellow
|
|
245
|
+
(150, "#ff7e00"), # 轻度污染 / orange
|
|
246
|
+
(200, "#ff0000"), # 中度污染 / red
|
|
247
|
+
(300, "#99004c"), # 重度污染 / purple
|
|
248
|
+
(500, "#7e0023"), # 严重污染 / maroon
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
AQI_EXCEEDANCE_THRESHOLD = 100 # AQI > 100 considered exceeding
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Station variable metadata: nc_name -> (unit, limit_key, limit_time)
|
|
255
|
+
# limit_key maps into AQ_LIMITS_2026; limit_time selects which sub-key.
|
|
256
|
+
# None means no standard exceedance check for this variable.
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
STATION_VAR_META: dict[str, tuple[str, str | None, str | None]] = {
|
|
259
|
+
"AQI": ("", None, None),
|
|
260
|
+
"PM2.5": ("ug/m3", None, None),
|
|
261
|
+
"PM2.5_24h": ("ug/m3", "PM2_5", "daily"),
|
|
262
|
+
"PM10": ("ug/m3", None, None),
|
|
263
|
+
"PM10_24h": ("ug/m3", "PM10", "daily"),
|
|
264
|
+
"SO2": ("ug/m3", "SO2", "hourly"),
|
|
265
|
+
"SO2_24h": ("ug/m3", "SO2", "daily"),
|
|
266
|
+
"NO2": ("ug/m3", "NO2", "hourly"),
|
|
267
|
+
"NO2_24h": ("ug/m3", "NO2", "daily"),
|
|
268
|
+
"O3": ("ug/m3", "O3", "hourly"),
|
|
269
|
+
"O3_24h": ("ug/m3", "O3", "hourly"),
|
|
270
|
+
"O3_8h": ("ug/m3", "O3", "8h"),
|
|
271
|
+
"O3_8h_24h": ("ug/m3", "O3", "8h"),
|
|
272
|
+
"CO": ("mg/m3", "CO", "hourly"),
|
|
273
|
+
"CO_24h": ("mg/m3", "CO", "daily"),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_exceedance_threshold(var_name: str) -> float | None:
|
|
278
|
+
"""Return the exceedance threshold for a station variable, or None."""
|
|
279
|
+
if var_name == "AQI":
|
|
280
|
+
return AQI_EXCEEDANCE_THRESHOLD
|
|
281
|
+
meta = STATION_VAR_META.get(var_name)
|
|
282
|
+
if meta is None:
|
|
283
|
+
return None
|
|
284
|
+
_, limit_key, limit_time = meta
|
|
285
|
+
if limit_key is None or limit_time is None:
|
|
286
|
+
return None
|
|
287
|
+
limits = AQ_LIMITS_2026.get(limit_key)
|
|
288
|
+
if limits is None:
|
|
289
|
+
return None
|
|
290
|
+
return limits.get(limit_time)
|
rsplot/fnr.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""FNR (Formaldehyde-to-NO2 Ratio) computation with temporal averaging.
|
|
2
|
+
|
|
3
|
+
TROPOMI daily HCHO coverage is typically 30-50% over China (HCHO has
|
|
4
|
+
lower SNR and stricter cloud filters than NO2), so per-day FNR maps are
|
|
5
|
+
too fragmented to diagnose NOx/VOC-limited regimes reliably. The
|
|
6
|
+
standard fix in the literature (Duncan 2010, Jin & Holloway 2015,
|
|
7
|
+
Souri 2020 et al.) is:
|
|
8
|
+
|
|
9
|
+
1. Average HCHO and NO2 columns *separately* over an N-day window
|
|
10
|
+
(typically 7 or 30 days).
|
|
11
|
+
2. Divide the averaged fields pixel-wise.
|
|
12
|
+
3. Mask pixels with too few valid days, and pixels where the mean
|
|
13
|
+
NO2 column is too low for the FNR interpretation to hold.
|
|
14
|
+
|
|
15
|
+
Regime classification (Duncan 2010):
|
|
16
|
+
|
|
17
|
+
FNR < 1.0 → VOC-limited (reducing VOC more effective)
|
|
18
|
+
1.0 ≤ FNR ≤ 2.0 → Transition
|
|
19
|
+
FNR > 2.0 → NOx-limited (reducing NOx more effective)
|
|
20
|
+
|
|
21
|
+
Both HCHO and NO2 readers return columns in 10^15 molec/cm², so FNR is
|
|
22
|
+
dimensionless and directly comparable with the literature.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timedelta
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.progress import track
|
|
34
|
+
|
|
35
|
+
from rsplot.geo.gridding import (
|
|
36
|
+
fill_nan_gaps,
|
|
37
|
+
grid_data,
|
|
38
|
+
mask_to_region,
|
|
39
|
+
smooth_grid,
|
|
40
|
+
)
|
|
41
|
+
from rsplot.readers import get_product_info
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Regime thresholds (Duncan et al. 2010, widely reused in the literature)
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
FNR_VOC_LIMIT = 1.0
|
|
51
|
+
FNR_NOX_LIMIT = 2.0
|
|
52
|
+
|
|
53
|
+
# Minimum mean NO2 (10^15 molec/cm²) below which FNR is not meaningful.
|
|
54
|
+
# Duncan used 1.5, but over China most non-urban pixels sit below that;
|
|
55
|
+
# keep 0.5 as a softer default and flag in the JSON sidecar.
|
|
56
|
+
NO2_MIN_COLUMN = 0.5
|
|
57
|
+
|
|
58
|
+
# Display clamp for the FNR colorbar — beyond this the regime classification
|
|
59
|
+
# has already saturated.
|
|
60
|
+
FNR_DISPLAY_MAX = 6.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Date window parsing
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
def _parse_yyyymmdd(value: str, label: str = "日期") -> datetime:
|
|
67
|
+
"""Parse a YYYYMMDD string and report invalid calendar dates clearly."""
|
|
68
|
+
try:
|
|
69
|
+
return datetime.strptime(value, "%Y%m%d")
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"{label}无效: '{value}',请检查年月日是否真实存在 "
|
|
73
|
+
f"(原始错误: {e})"
|
|
74
|
+
) from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_date_window(arg: str, default_days: int) -> list[str]:
|
|
78
|
+
"""Parse a ``YYYYMMDD`` or ``YYYYMMDD-YYYYMMDD`` argument into a date list.
|
|
79
|
+
|
|
80
|
+
- Single date → window is the ``default_days``-day period ending on
|
|
81
|
+
that date (inclusive).
|
|
82
|
+
- ``START-END`` → inclusive range; ``START`` and ``END`` can be equal.
|
|
83
|
+
|
|
84
|
+
Returns YYYYMMDD strings in ascending order.
|
|
85
|
+
"""
|
|
86
|
+
if "-" in arg:
|
|
87
|
+
parts = arg.split("-")
|
|
88
|
+
if len(parts) != 2 or any(len(p) != 8 or not p.isdigit() for p in parts):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"日期范围格式错误: '{arg}',应为 YYYYMMDD-YYYYMMDD"
|
|
91
|
+
)
|
|
92
|
+
start = _parse_yyyymmdd(parts[0], "开始日期")
|
|
93
|
+
end = _parse_yyyymmdd(parts[1], "结束日期")
|
|
94
|
+
if end < start:
|
|
95
|
+
raise ValueError(f"日期范围起止颠倒: {arg}")
|
|
96
|
+
else:
|
|
97
|
+
if len(arg) != 8 or not arg.isdigit():
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"日期格式错误: '{arg}',应为 YYYYMMDD 或 YYYYMMDD-YYYYMMDD"
|
|
100
|
+
)
|
|
101
|
+
end = _parse_yyyymmdd(arg)
|
|
102
|
+
start = end - timedelta(days=default_days - 1)
|
|
103
|
+
|
|
104
|
+
out = []
|
|
105
|
+
cur = start
|
|
106
|
+
while cur <= end:
|
|
107
|
+
out.append(cur.strftime("%Y%m%d"))
|
|
108
|
+
cur += timedelta(days=1)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Window-mean helper (used by both `rsplot fnr` and `rsplot raster --days`)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
@dataclass
|
|
116
|
+
class WindowResult:
|
|
117
|
+
"""Per-product time-averaged grid over a date window.
|
|
118
|
+
|
|
119
|
+
The mean field is *not* coverage-masked — callers apply their own
|
|
120
|
+
``n_min`` threshold against :attr:`n_valid` (FNR uses a combined
|
|
121
|
+
NO2 ∧ HCHO mask; raster uses its own product-only threshold).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
LON: np.ndarray
|
|
125
|
+
LAT: np.ndarray
|
|
126
|
+
grid: np.ndarray # time-mean, NaN where every day was missing
|
|
127
|
+
n_valid: np.ndarray # int count of valid days per pixel
|
|
128
|
+
dates_requested: list[str]
|
|
129
|
+
dates_found: list[str]
|
|
130
|
+
dates_failed: dict[str, str]
|
|
131
|
+
n_pixels_total: int # sum of swath pixels read across days
|
|
132
|
+
n_files_total: int # sum of swath files scanned
|
|
133
|
+
n_broken_total: int # sum of unreadable files
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compute_window_mean(
|
|
137
|
+
*,
|
|
138
|
+
reader,
|
|
139
|
+
data_dir: str,
|
|
140
|
+
dates: list[str],
|
|
141
|
+
extent: tuple[float, float, float, float],
|
|
142
|
+
res: float,
|
|
143
|
+
qa_threshold: float,
|
|
144
|
+
label: str = "数据",
|
|
145
|
+
console: Console | None = None,
|
|
146
|
+
) -> WindowResult:
|
|
147
|
+
"""Read each date, regrid, return time-mean + per-pixel valid-day count.
|
|
148
|
+
|
|
149
|
+
Each day that succeeds contributes one ``(ny, nx)`` grid to the stack;
|
|
150
|
+
failed days (missing files / no valid pixels) become all-NaN slabs
|
|
151
|
+
so the caller can still tell which dates fell out.
|
|
152
|
+
"""
|
|
153
|
+
if console is None:
|
|
154
|
+
console = Console()
|
|
155
|
+
|
|
156
|
+
stack: list[np.ndarray | None] = []
|
|
157
|
+
dates_found: list[str] = []
|
|
158
|
+
dates_failed: dict[str, str] = {}
|
|
159
|
+
n_pix_total = 0
|
|
160
|
+
n_files_total = 0
|
|
161
|
+
n_broken_total = 0
|
|
162
|
+
LON: np.ndarray | None = None
|
|
163
|
+
LAT: np.ndarray | None = None
|
|
164
|
+
|
|
165
|
+
for d in track(dates, description=f"[cyan]读取 {label} 窗口..."):
|
|
166
|
+
try:
|
|
167
|
+
s = reader.read(data_dir, d, extent, qa_threshold=qa_threshold)
|
|
168
|
+
_LON, _LAT, g = grid_data(s.lon, s.lat, s.values, extent, res)
|
|
169
|
+
LON, LAT = _LON, _LAT
|
|
170
|
+
stack.append(g)
|
|
171
|
+
dates_found.append(d)
|
|
172
|
+
n_pix_total += int(s.n_pixels)
|
|
173
|
+
n_files_total += int(s.n_files)
|
|
174
|
+
n_broken_total += int(s.n_broken)
|
|
175
|
+
except (FileNotFoundError, ValueError) as e:
|
|
176
|
+
dates_failed[d] = str(e).splitlines()[0]
|
|
177
|
+
stack.append(None)
|
|
178
|
+
|
|
179
|
+
if LON is None or LAT is None:
|
|
180
|
+
first_errors = "\n ".join(
|
|
181
|
+
f"{d}: {msg}" for d, msg in list(dates_failed.items())[:5]
|
|
182
|
+
)
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"窗口 {dates[0]}~{dates[-1]} 内 {label} 全部失败:\n {first_errors}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
shape = LON.shape
|
|
188
|
+
filled = [g if g is not None else np.full(shape, np.nan) for g in stack]
|
|
189
|
+
arr = np.stack(filled, axis=0) # (n_days, ny, nx)
|
|
190
|
+
with np.errstate(invalid="ignore"):
|
|
191
|
+
mean = np.nanmean(arr, axis=0)
|
|
192
|
+
n_valid = np.isfinite(arr).sum(axis=0).astype(np.int16)
|
|
193
|
+
|
|
194
|
+
return WindowResult(
|
|
195
|
+
LON=LON,
|
|
196
|
+
LAT=LAT,
|
|
197
|
+
grid=mean,
|
|
198
|
+
n_valid=n_valid,
|
|
199
|
+
dates_requested=list(dates),
|
|
200
|
+
dates_found=dates_found,
|
|
201
|
+
dates_failed=dates_failed,
|
|
202
|
+
n_pixels_total=n_pix_total,
|
|
203
|
+
n_files_total=n_files_total,
|
|
204
|
+
n_broken_total=n_broken_total,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# FNR result container
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
@dataclass
|
|
212
|
+
class FnrResult:
|
|
213
|
+
"""Output of :func:`compute_fnr`.
|
|
214
|
+
|
|
215
|
+
Grids are all ``(ny, nx)`` on the same LON/LAT meshgrid.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
LON: np.ndarray
|
|
219
|
+
LAT: np.ndarray
|
|
220
|
+
fnr: np.ndarray # HCHO_mean / NO2_mean, masked
|
|
221
|
+
hcho_mean: np.ndarray # raw masked mean, 10^15 molec/cm²
|
|
222
|
+
no2_mean: np.ndarray # raw masked mean, 10^15 molec/cm²
|
|
223
|
+
hcho_mean_filled: np.ndarray # small-gap-filled diagnostic field
|
|
224
|
+
no2_mean_filled: np.ndarray # small-gap-filled diagnostic field
|
|
225
|
+
n_valid_hcho: np.ndarray # int count of valid days per pixel
|
|
226
|
+
n_valid_no2: np.ndarray # same for NO2
|
|
227
|
+
dates_requested: list[str]
|
|
228
|
+
dates_found_hcho: list[str]
|
|
229
|
+
dates_found_no2: list[str]
|
|
230
|
+
dates_failed_hcho: dict[str, str] # date → error message
|
|
231
|
+
dates_failed_no2: dict[str, str]
|
|
232
|
+
n_min: int
|
|
233
|
+
no2_min_column: float
|
|
234
|
+
diagnostic_masks: dict[str, np.ndarray]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Main entry point
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
def compute_fnr(
|
|
241
|
+
region: RegionInfo,
|
|
242
|
+
dates: list[str],
|
|
243
|
+
*,
|
|
244
|
+
res: float,
|
|
245
|
+
qa_threshold: float,
|
|
246
|
+
n_min: int,
|
|
247
|
+
raster_dir_no2: str,
|
|
248
|
+
raster_dir_hcho: str,
|
|
249
|
+
no2_min_column: float = NO2_MIN_COLUMN,
|
|
250
|
+
smooth_sigma: float | None = None,
|
|
251
|
+
console: Console | None = None,
|
|
252
|
+
) -> FnrResult:
|
|
253
|
+
"""Read NO2 + HCHO over a date window and compute per-pixel FNR.
|
|
254
|
+
|
|
255
|
+
Each daily swath/grid is first binned onto the same target grid, then
|
|
256
|
+
the two time-stacks are averaged and divided. Pixels with fewer than
|
|
257
|
+
``n_min`` valid days in *either* product, or with mean NO2 below
|
|
258
|
+
``no2_min_column``, are masked to NaN.
|
|
259
|
+
"""
|
|
260
|
+
if console is None:
|
|
261
|
+
console = Console()
|
|
262
|
+
|
|
263
|
+
buf = 1.0 # extent buffer to avoid edge artifacts during regridding
|
|
264
|
+
read_extent = (
|
|
265
|
+
region.extent[0] - buf,
|
|
266
|
+
region.extent[1] + buf,
|
|
267
|
+
region.extent[2] - buf,
|
|
268
|
+
region.extent[3] + buf,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
no2_reader = get_product_info("no2").reader_cls()
|
|
272
|
+
hcho_reader = get_product_info("hcho").reader_cls()
|
|
273
|
+
|
|
274
|
+
# --- Read each product over the window via the shared helper ---
|
|
275
|
+
no2_win = compute_window_mean(
|
|
276
|
+
reader=no2_reader,
|
|
277
|
+
data_dir=raster_dir_no2,
|
|
278
|
+
dates=dates,
|
|
279
|
+
extent=read_extent,
|
|
280
|
+
res=res,
|
|
281
|
+
qa_threshold=qa_threshold,
|
|
282
|
+
label="NO2",
|
|
283
|
+
console=console,
|
|
284
|
+
)
|
|
285
|
+
hcho_win = compute_window_mean(
|
|
286
|
+
reader=hcho_reader,
|
|
287
|
+
data_dir=raster_dir_hcho,
|
|
288
|
+
dates=dates,
|
|
289
|
+
extent=read_extent,
|
|
290
|
+
res=res,
|
|
291
|
+
qa_threshold=qa_threshold,
|
|
292
|
+
label="HCHO",
|
|
293
|
+
console=console,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
LON, LAT = no2_win.LON, no2_win.LAT
|
|
297
|
+
shape = LON.shape
|
|
298
|
+
|
|
299
|
+
# --- Combined coverage mask: need n_min valid days in BOTH products ---
|
|
300
|
+
coverage_ok = (no2_win.n_valid >= n_min) & (hcho_win.n_valid >= n_min)
|
|
301
|
+
|
|
302
|
+
# --- NO2-min mask: avoid dividing by tiny NO2 backgrounds ---
|
|
303
|
+
no2_ok = np.isfinite(no2_win.grid) & (no2_win.grid >= no2_min_column)
|
|
304
|
+
|
|
305
|
+
# HCHO retrieval noise can yield negative columns; do not let those
|
|
306
|
+
# become negative FNR values that classify as VOC-limited.
|
|
307
|
+
hcho_ok = np.isfinite(hcho_win.grid) & (hcho_win.grid >= 0)
|
|
308
|
+
|
|
309
|
+
valid = coverage_ok & no2_ok & hcho_ok
|
|
310
|
+
|
|
311
|
+
fnr = np.full(shape, np.nan)
|
|
312
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
313
|
+
fnr[valid] = hcho_win.grid[valid] / no2_win.grid[valid]
|
|
314
|
+
|
|
315
|
+
# Keep raw mean fields for JSON statistics, and separately prepare
|
|
316
|
+
# small-gap-filled diagnostic fields for users who want a less holey
|
|
317
|
+
# view of the underlying columns. The FNR field itself is never filled.
|
|
318
|
+
hcho_raw = mask_to_region(LON, LAT, hcho_win.grid, region.geometry)
|
|
319
|
+
no2_raw = mask_to_region(LON, LAT, no2_win.grid, region.geometry)
|
|
320
|
+
hcho_filled = fill_nan_gaps(LON, LAT, hcho_win.grid)
|
|
321
|
+
no2_filled = fill_nan_gaps(LON, LAT, no2_win.grid)
|
|
322
|
+
|
|
323
|
+
# --- Mask to region boundary ---
|
|
324
|
+
fnr = mask_to_region(LON, LAT, fnr, region.geometry)
|
|
325
|
+
hcho_filled = mask_to_region(LON, LAT, hcho_filled, region.geometry)
|
|
326
|
+
no2_filled = mask_to_region(LON, LAT, no2_filled, region.geometry)
|
|
327
|
+
|
|
328
|
+
if smooth_sigma is not None:
|
|
329
|
+
fnr_valid_mask = np.isfinite(fnr)
|
|
330
|
+
fnr = smooth_grid(fnr, region.geometry, LON, LAT, sigma=smooth_sigma)
|
|
331
|
+
# smooth_grid interpolates over NaNs before convolution. Restore the
|
|
332
|
+
# scientific validity mask so smoothing cannot create new FNR pixels.
|
|
333
|
+
fnr[~fnr_valid_mask] = np.nan
|
|
334
|
+
|
|
335
|
+
return FnrResult(
|
|
336
|
+
LON=LON,
|
|
337
|
+
LAT=LAT,
|
|
338
|
+
fnr=fnr,
|
|
339
|
+
hcho_mean=hcho_raw,
|
|
340
|
+
no2_mean=no2_raw,
|
|
341
|
+
hcho_mean_filled=hcho_filled,
|
|
342
|
+
no2_mean_filled=no2_filled,
|
|
343
|
+
n_valid_hcho=hcho_win.n_valid,
|
|
344
|
+
n_valid_no2=no2_win.n_valid,
|
|
345
|
+
dates_requested=list(dates),
|
|
346
|
+
dates_found_hcho=hcho_win.dates_found,
|
|
347
|
+
dates_found_no2=no2_win.dates_found,
|
|
348
|
+
dates_failed_hcho=hcho_win.dates_failed,
|
|
349
|
+
dates_failed_no2=no2_win.dates_failed,
|
|
350
|
+
n_min=n_min,
|
|
351
|
+
no2_min_column=no2_min_column,
|
|
352
|
+
diagnostic_masks={
|
|
353
|
+
"coverage_ok": coverage_ok,
|
|
354
|
+
"no2_ok": no2_ok,
|
|
355
|
+
"hcho_ok": hcho_ok,
|
|
356
|
+
"final_valid": np.isfinite(fnr),
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Regime helpers (used by the plotter and the JSON builder)
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
def classify_regime(fnr: np.ndarray) -> np.ndarray:
|
|
365
|
+
"""Return int codes: 0=VOC-limited, 1=transition, 2=NOx-limited, -1=NaN."""
|
|
366
|
+
code = np.full(fnr.shape, -1, dtype=np.int8)
|
|
367
|
+
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
|
|
371
|
+
return code
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def regime_percentages(fnr: np.ndarray) -> dict[str, float]:
|
|
375
|
+
"""% of valid pixels in each regime."""
|
|
376
|
+
code = classify_regime(fnr)
|
|
377
|
+
valid_total = int((code >= 0).sum())
|
|
378
|
+
if valid_total == 0:
|
|
379
|
+
return {"voc_limited": 0.0, "transition": 0.0, "nox_limited": 0.0}
|
|
380
|
+
return {
|
|
381
|
+
"voc_limited": round(100 * int((code == 0).sum()) / valid_total, 1),
|
|
382
|
+
"transition": round(100 * int((code == 1).sum()) / valid_total, 1),
|
|
383
|
+
"nox_limited": round(100 * int((code == 2).sum()) / valid_total, 1),
|
|
384
|
+
}
|
rsplot/geo/__init__.py
ADDED
|
File without changes
|