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/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