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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Font registration and matplotlib style configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import matplotlib as mpl
|
|
8
|
+
from matplotlib import font_manager
|
|
9
|
+
|
|
10
|
+
from rsplot.plotting.colormaps import register_rsplot_colormaps
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_fonts(font_dir: str) -> None:
|
|
14
|
+
"""Register Chinese fonts and set matplotlib defaults.
|
|
15
|
+
|
|
16
|
+
Looks for Microsoft YaHei in font_dir. Falls back gracefully
|
|
17
|
+
if fonts are not found (labels will use default sans-serif).
|
|
18
|
+
"""
|
|
19
|
+
candidates = [
|
|
20
|
+
os.path.join(font_dir, "MicrosoftYaHei", "微软雅黑.ttf"),
|
|
21
|
+
os.path.join(font_dir, "MicrosoftYaHei", "微软雅黑粗体.ttf"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
registered = False
|
|
25
|
+
for fp in candidates:
|
|
26
|
+
if os.path.exists(fp):
|
|
27
|
+
font_manager.fontManager.addfont(fp)
|
|
28
|
+
registered = True
|
|
29
|
+
|
|
30
|
+
if registered:
|
|
31
|
+
mpl.rcParams["font.family"] = "sans-serif"
|
|
32
|
+
mpl.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
|
|
33
|
+
|
|
34
|
+
mpl.rcParams["axes.unicode_minus"] = False
|
|
35
|
+
register_rsplot_colormaps()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Satellite data readers and product registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from rsplot.readers.base import BaseReader
|
|
11
|
+
from rsplot.readers.tropomi_hcho import TropomiHCHOReader
|
|
12
|
+
from rsplot.readers.tropomi_no2 import TropomiNO2Reader
|
|
13
|
+
from rsplot.readers.tropomi_o3 import TropomiO3Reader
|
|
14
|
+
|
|
15
|
+
HCHO_CMAP = "rsplot_hcho"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ProductInfo:
|
|
20
|
+
"""Per-product metadata: reader class, display settings, auto-range config."""
|
|
21
|
+
|
|
22
|
+
reader_cls: type[BaseReader]
|
|
23
|
+
colorbar_label: str
|
|
24
|
+
cmap: str
|
|
25
|
+
vmin: float | None # None = auto from data percentiles
|
|
26
|
+
vmax: float | None
|
|
27
|
+
min_range: float # minimum vmax-vmin to avoid exaggerating small differences
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PRODUCT_REGISTRY: dict[str, ProductInfo] = {
|
|
31
|
+
"no2": ProductInfo(
|
|
32
|
+
reader_cls=TropomiNO2Reader,
|
|
33
|
+
colorbar_label="NO2 VCD (x10^15 molec/cm2)",
|
|
34
|
+
cmap="YlOrRd",
|
|
35
|
+
vmin=0.0,
|
|
36
|
+
vmax=20.0,
|
|
37
|
+
min_range=5.0,
|
|
38
|
+
),
|
|
39
|
+
"o3": ProductInfo(
|
|
40
|
+
reader_cls=TropomiO3Reader,
|
|
41
|
+
colorbar_label="O3 Total Column (DU)",
|
|
42
|
+
cmap="YlOrRd",
|
|
43
|
+
vmin=None,
|
|
44
|
+
vmax=None,
|
|
45
|
+
min_range=30.0,
|
|
46
|
+
),
|
|
47
|
+
"hcho": ProductInfo(
|
|
48
|
+
reader_cls=TropomiHCHOReader,
|
|
49
|
+
colorbar_label="HCHO VCD (x10^15 molec/cm2)",
|
|
50
|
+
cmap=HCHO_CMAP,
|
|
51
|
+
vmin=None, # unit not yet confirmed — let auto-range pick
|
|
52
|
+
vmax=None,
|
|
53
|
+
min_range=5.0,
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_product_info(product: str) -> ProductInfo:
|
|
59
|
+
"""Return ProductInfo for a product name."""
|
|
60
|
+
key = product.lower()
|
|
61
|
+
if key not in PRODUCT_REGISTRY:
|
|
62
|
+
available = ", ".join(PRODUCT_REGISTRY)
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Unsupported product '{product}'. Available: {available}"
|
|
65
|
+
)
|
|
66
|
+
return PRODUCT_REGISTRY[key]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def auto_vrange(
|
|
70
|
+
grid: np.ndarray, prod_info: ProductInfo
|
|
71
|
+
) -> tuple[float, float]:
|
|
72
|
+
"""Compute vmin/vmax from data, respecting min_range and rounding nicely."""
|
|
73
|
+
p2 = float(np.nanpercentile(grid, 2))
|
|
74
|
+
p98 = float(np.nanpercentile(grid, 98))
|
|
75
|
+
span = p98 - p2
|
|
76
|
+
|
|
77
|
+
# Enforce minimum range: expand symmetrically around the center
|
|
78
|
+
if span < prod_info.min_range:
|
|
79
|
+
center = (p2 + p98) / 2
|
|
80
|
+
p2 = center - prod_info.min_range / 2
|
|
81
|
+
p98 = center + prod_info.min_range / 2
|
|
82
|
+
span = prod_info.min_range
|
|
83
|
+
|
|
84
|
+
# Round to nice numbers: pick step from span magnitude
|
|
85
|
+
if span < 10:
|
|
86
|
+
step = 1.0
|
|
87
|
+
elif span < 50:
|
|
88
|
+
step = 5.0
|
|
89
|
+
elif span < 100:
|
|
90
|
+
step = 10.0
|
|
91
|
+
else:
|
|
92
|
+
step = 20.0
|
|
93
|
+
|
|
94
|
+
vmin = math.floor(p2 / step) * step
|
|
95
|
+
vmax = math.ceil(p98 / step) * step
|
|
96
|
+
return vmin, vmax
|
rsplot/readers/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Abstract base class for satellite data readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import xarray as xr
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SwathData:
|
|
14
|
+
"""Raw swath data after QA filtering and spatial subsetting."""
|
|
15
|
+
|
|
16
|
+
lon: np.ndarray # 1-D
|
|
17
|
+
lat: np.ndarray # 1-D
|
|
18
|
+
values: np.ndarray # 1-D, in display units
|
|
19
|
+
n_pixels: int
|
|
20
|
+
n_files: int
|
|
21
|
+
n_broken: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseReader(ABC):
|
|
25
|
+
"""Interface for reading a single-day satellite product."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def read(
|
|
29
|
+
self,
|
|
30
|
+
data_dir: str,
|
|
31
|
+
date: str,
|
|
32
|
+
extent: tuple[float, float, float, float],
|
|
33
|
+
qa_threshold: float = 0.5,
|
|
34
|
+
) -> SwathData:
|
|
35
|
+
"""Read and filter all orbits for a given date and spatial extent.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
data_dir : root directory containing product files.
|
|
40
|
+
date : date string, e.g. "20260208".
|
|
41
|
+
extent : (lon_min, lon_max, lat_min, lat_max).
|
|
42
|
+
qa_threshold : minimum QA value to keep.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
SwathData with concatenated 1-D arrays.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_tropomi_product_arrays(
|
|
52
|
+
path: str, value_name: str
|
|
53
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
54
|
+
"""Read lon/lat/value/qa from either flattened or grouped TROPOMI files.
|
|
55
|
+
|
|
56
|
+
Some local archives flatten the ``/PRODUCT`` group into variables like
|
|
57
|
+
``PRODUCT_longitude``; official OFFL files usually keep a real PRODUCT
|
|
58
|
+
NetCDF group with plain variable names. This helper accepts both layouts.
|
|
59
|
+
"""
|
|
60
|
+
flat_names = {
|
|
61
|
+
"lon": "PRODUCT_longitude",
|
|
62
|
+
"lat": "PRODUCT_latitude",
|
|
63
|
+
"value": f"PRODUCT_{value_name}",
|
|
64
|
+
"qa": "PRODUCT_qa_value",
|
|
65
|
+
}
|
|
66
|
+
group_names = {
|
|
67
|
+
"lon": "longitude",
|
|
68
|
+
"lat": "latitude",
|
|
69
|
+
"value": value_name,
|
|
70
|
+
"qa": "qa_value",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
errors: list[str] = []
|
|
74
|
+
for group, names in ((None, flat_names), ("PRODUCT", group_names)):
|
|
75
|
+
try:
|
|
76
|
+
kwargs = {} if group is None else {"group": group}
|
|
77
|
+
ds = xr.open_dataset(path, **kwargs)
|
|
78
|
+
except (OSError, ValueError) as e:
|
|
79
|
+
label = "root" if group is None else group
|
|
80
|
+
errors.append(f"{label}: {e}")
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
lon = ds[names["lon"]].squeeze().values
|
|
85
|
+
lat = ds[names["lat"]].squeeze().values
|
|
86
|
+
value = ds[names["value"]].squeeze().values
|
|
87
|
+
qa = ds[names["qa"]].squeeze().values
|
|
88
|
+
return lon, lat, value, qa
|
|
89
|
+
except KeyError as e:
|
|
90
|
+
label = "root" if group is None else group
|
|
91
|
+
errors.append(f"{label}: missing {e}")
|
|
92
|
+
finally:
|
|
93
|
+
ds.close()
|
|
94
|
+
|
|
95
|
+
msg = "; ".join(str(e).splitlines()[0] for e in errors)
|
|
96
|
+
raise ValueError(f"无法读取 TROPOMI PRODUCT 变量: {msg}")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""National ground monitoring station (国控站点) reader.
|
|
2
|
+
|
|
3
|
+
Reads hourly combined NC files from /home/zzgsg/data/guokong_combine/.
|
|
4
|
+
File naming: {YYYYMMDDHH}.nc, e.g. 2026041415.nc.
|
|
5
|
+
|
|
6
|
+
Accepts both YYYYMMDDHH (exact hour) and YYYYMMDD (auto-picks latest hour).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import xarray as xr
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from shapely.geometry.base import BaseGeometry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StationData:
|
|
25
|
+
"""Filtered station observations for one variable."""
|
|
26
|
+
|
|
27
|
+
id: np.ndarray # 1-D string array
|
|
28
|
+
lon: np.ndarray # 1-D float
|
|
29
|
+
lat: np.ndarray # 1-D float
|
|
30
|
+
values: np.ndarray # 1-D float (NaN where invalid)
|
|
31
|
+
var_name: str
|
|
32
|
+
n_stations: int
|
|
33
|
+
n_valid: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_stations(
|
|
37
|
+
data_dir: str,
|
|
38
|
+
datetime_str: str,
|
|
39
|
+
var_name: str,
|
|
40
|
+
extent: tuple[float, float, float, float] | None = None,
|
|
41
|
+
) -> tuple[StationData, str]:
|
|
42
|
+
"""Read station data for a given hour and variable.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
data_dir : directory containing {YYYYMMDDHH}.nc files.
|
|
47
|
+
datetime_str : YYYYMMDDHH (exact hour) or YYYYMMDD (latest hour).
|
|
48
|
+
var_name : variable name in the NC file (e.g. "AQI", "PM2.5", "O3").
|
|
49
|
+
extent : optional (lon_min, lon_max, lat_min, lat_max) for spatial filter.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
tuple of (StationData, resolved_datetime_str).
|
|
54
|
+
"""
|
|
55
|
+
if not datetime_str.isdigit() or len(datetime_str) not in (8, 10):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"datetime 参数 '{datetime_str}' 格式不正确,"
|
|
58
|
+
f"请使用 YYYYMMDD 或 YYYYMMDDHH,如 20260414 或 2026041415。"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# If 8-digit date, find the latest available hourly file for that day
|
|
62
|
+
if len(datetime_str) == 8:
|
|
63
|
+
pattern = os.path.join(data_dir, f"{datetime_str}??.nc")
|
|
64
|
+
matches = sorted(glob.glob(pattern))
|
|
65
|
+
if not matches:
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"未找到 {datetime_str} 当天的站点数据文件: {pattern}\n"
|
|
68
|
+
f"请检查日期和 --data-dir 路径。"
|
|
69
|
+
)
|
|
70
|
+
path = matches[-1] # latest hour
|
|
71
|
+
datetime_str = os.path.basename(path).removesuffix(".nc")
|
|
72
|
+
else:
|
|
73
|
+
path = os.path.join(data_dir, f"{datetime_str}.nc")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
ds = xr.open_dataset(path)
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
raise FileNotFoundError(
|
|
79
|
+
f"未找到站点数据文件: {path}\n"
|
|
80
|
+
f"请检查 datetime 参数和 --data-dir 路径。"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
lon = ds["lon"].values.astype(float)
|
|
85
|
+
lat = ds["lat"].values.astype(float)
|
|
86
|
+
station_id = ds["id"].values
|
|
87
|
+
|
|
88
|
+
# Read variable — xarray auto-applies scale_factor/add_offset for CO
|
|
89
|
+
raw = ds[var_name].values.astype(float)
|
|
90
|
+
finally:
|
|
91
|
+
ds.close()
|
|
92
|
+
|
|
93
|
+
# Fill values (65535 for ushort) → NaN
|
|
94
|
+
raw[raw >= 65534] = np.nan
|
|
95
|
+
|
|
96
|
+
# Spatial filter
|
|
97
|
+
if extent is not None:
|
|
98
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
99
|
+
mask = (
|
|
100
|
+
(lon >= lon_min)
|
|
101
|
+
& (lon <= lon_max)
|
|
102
|
+
& (lat >= lat_min)
|
|
103
|
+
& (lat <= lat_max)
|
|
104
|
+
)
|
|
105
|
+
lon = lon[mask]
|
|
106
|
+
lat = lat[mask]
|
|
107
|
+
raw = raw[mask]
|
|
108
|
+
station_id = station_id[mask]
|
|
109
|
+
|
|
110
|
+
n_valid = int(np.isfinite(raw).sum())
|
|
111
|
+
|
|
112
|
+
return StationData(
|
|
113
|
+
id=station_id,
|
|
114
|
+
lon=lon,
|
|
115
|
+
lat=lat,
|
|
116
|
+
values=raw,
|
|
117
|
+
var_name=var_name,
|
|
118
|
+
n_stations=len(lon),
|
|
119
|
+
n_valid=n_valid,
|
|
120
|
+
), datetime_str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def mask_stations_to_region(
|
|
124
|
+
data: StationData, geometry: BaseGeometry
|
|
125
|
+
) -> StationData:
|
|
126
|
+
"""Keep only stations inside the focus region geometry."""
|
|
127
|
+
from shapely.vectorized import contains
|
|
128
|
+
|
|
129
|
+
inside = contains(geometry.buffer(1e-9), data.lon, data.lat)
|
|
130
|
+
return _subset_stations(data, inside)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _subset_stations(data: StationData, mask: np.ndarray) -> StationData:
|
|
134
|
+
values = data.values[mask]
|
|
135
|
+
return StationData(
|
|
136
|
+
id=np.asarray(data.id)[mask],
|
|
137
|
+
lon=data.lon[mask],
|
|
138
|
+
lat=data.lat[mask],
|
|
139
|
+
values=values,
|
|
140
|
+
var_name=data.var_name,
|
|
141
|
+
n_stations=int(mask.sum()),
|
|
142
|
+
n_valid=int(np.isfinite(values).sum()),
|
|
143
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""TROPOMI OFFL L2 HCHO reader.
|
|
2
|
+
|
|
3
|
+
Reads ``S5P_OFFL_L2__HCHO___`` per-orbit swath files, applies QA
|
|
4
|
+
filtering, spatial subsetting, and unit conversion to ``10^15 molec/cm²``.
|
|
5
|
+
|
|
6
|
+
Filename pattern (one file per orbit, ~16 files per day)::
|
|
7
|
+
|
|
8
|
+
S5P_OFFL_L2__HCHO___{YYYYMMDD}T{HHMMSS}_{end}_{orbit}_{coll}_{proc}_{tstamp}.nc.zip
|
|
9
|
+
|
|
10
|
+
Two structural differences from the NO2 / O3 NRTI readers:
|
|
11
|
+
|
|
12
|
+
1. **Real NetCDF groups, not flat names.** OFFL files keep the
|
|
13
|
+
``/PRODUCT`` group hierarchy intact, so xarray's root-level dataset
|
|
14
|
+
is empty. We open the ``PRODUCT`` group explicitly and address
|
|
15
|
+
variables by their plain name (``longitude``, ``qa_value`` …) rather
|
|
16
|
+
than the underscore-flattened ``PRODUCT_longitude`` form the NRTI
|
|
17
|
+
readers use.
|
|
18
|
+
2. **Misleading ``.nc.zip`` extension.** ``file(1)`` reports each as
|
|
19
|
+
native HDF5 / NetCDF4, so xarray reads them directly without any
|
|
20
|
+
unzip step. We also accept plain ``.nc`` in case files are
|
|
21
|
+
re-distributed without the suffix.
|
|
22
|
+
|
|
23
|
+
Unit: the source attribute on the variable is ``mol m-2``. We convert
|
|
24
|
+
to ``10^15 molec/cm²`` so HCHO matches the NO2 colorbar units and so
|
|
25
|
+
FNR = HCHO / NO2 is a dimensionless ratio directly comparable with the
|
|
26
|
+
literature.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import glob
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
from rich.progress import track
|
|
36
|
+
|
|
37
|
+
from rsplot.readers.base import (
|
|
38
|
+
BaseReader,
|
|
39
|
+
SwathData,
|
|
40
|
+
read_tropomi_product_arrays,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# mol/m² -> 10^15 molec/cm² (same convention as the NO2 reader)
|
|
44
|
+
UNIT_CONVERSION = 6.02214076e23 * 1e-4 * 1e-15
|
|
45
|
+
|
|
46
|
+
VALUE_NAME = "formaldehyde_tropospheric_vertical_column"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TropomiHCHOReader(BaseReader):
|
|
50
|
+
"""Reader for TROPOMI OFFL L2 HCHO tropospheric column files."""
|
|
51
|
+
|
|
52
|
+
def read(
|
|
53
|
+
self,
|
|
54
|
+
data_dir: str,
|
|
55
|
+
date: str,
|
|
56
|
+
extent: tuple[float, float, float, float],
|
|
57
|
+
qa_threshold: float = 0.5,
|
|
58
|
+
) -> SwathData:
|
|
59
|
+
# Match both `.nc` and `.nc.zip` (the latter is HDF5 underneath
|
|
60
|
+
# despite the misleading extension in this archive).
|
|
61
|
+
patterns = [
|
|
62
|
+
os.path.join(data_dir, f"S5P_*_L2__HCHO___{date}T*.nc"),
|
|
63
|
+
os.path.join(data_dir, f"S5P_*_L2__HCHO___{date}T*.nc.zip"),
|
|
64
|
+
]
|
|
65
|
+
files: list[str] = []
|
|
66
|
+
for p in patterns:
|
|
67
|
+
files.extend(glob.glob(p))
|
|
68
|
+
files = sorted(set(files))
|
|
69
|
+
if not files:
|
|
70
|
+
raise FileNotFoundError(
|
|
71
|
+
f"未找到匹配文件: \n "
|
|
72
|
+
+ "\n ".join(patterns)
|
|
73
|
+
+ f"\n请检查 --hcho-dir / --data-dir 和 --date={date}。"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
77
|
+
all_lon, all_lat, all_hcho = [], [], []
|
|
78
|
+
broken = 0
|
|
79
|
+
broken_examples: list[str] = []
|
|
80
|
+
|
|
81
|
+
for f in track(files, description="[cyan]读取 HCHO 轨道..."):
|
|
82
|
+
try:
|
|
83
|
+
lon, lat, hcho, qa = read_tropomi_product_arrays(f, VALUE_NAME)
|
|
84
|
+
except (OSError, ValueError, KeyError) as e:
|
|
85
|
+
broken += 1
|
|
86
|
+
if len(broken_examples) < 3:
|
|
87
|
+
broken_examples.append(
|
|
88
|
+
f"{os.path.basename(f)}: {str(e).splitlines()[0]}"
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
mask = (
|
|
93
|
+
(qa > qa_threshold)
|
|
94
|
+
& (lon >= lon_min)
|
|
95
|
+
& (lon <= lon_max)
|
|
96
|
+
& (lat >= lat_min)
|
|
97
|
+
& (lat <= lat_max)
|
|
98
|
+
& np.isfinite(hcho)
|
|
99
|
+
& (hcho < 1e30)
|
|
100
|
+
)
|
|
101
|
+
if mask.sum() > 0:
|
|
102
|
+
all_lon.append(lon[mask])
|
|
103
|
+
all_lat.append(lat[mask])
|
|
104
|
+
all_hcho.append(hcho[mask] * UNIT_CONVERSION)
|
|
105
|
+
|
|
106
|
+
if not all_lon:
|
|
107
|
+
broken_msg = (
|
|
108
|
+
"\n损坏/不可读示例:\n " + "\n ".join(broken_examples)
|
|
109
|
+
if broken_examples
|
|
110
|
+
else ""
|
|
111
|
+
)
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{date} 当天 HCHO 无有效数据覆盖目标区域 {extent}。\n"
|
|
114
|
+
f"共扫描 {len(files)} 个文件,损坏 {broken} 个;"
|
|
115
|
+
f"可尝试放宽 --qa 阈值。"
|
|
116
|
+
f"{broken_msg}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return SwathData(
|
|
120
|
+
lon=np.concatenate(all_lon),
|
|
121
|
+
lat=np.concatenate(all_lat),
|
|
122
|
+
values=np.concatenate(all_hcho),
|
|
123
|
+
n_pixels=sum(len(a) for a in all_lon),
|
|
124
|
+
n_files=len(files),
|
|
125
|
+
n_broken=broken,
|
|
126
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""TROPOMI NO2 reader.
|
|
2
|
+
|
|
3
|
+
Reads S5P_*_L2__NO2____ files, applies QA filtering, spatial subsetting,
|
|
4
|
+
and unit conversion to 10^15 molec/cm2. Supports both flattened NRT-style
|
|
5
|
+
variables and official PRODUCT-group files.
|
|
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
|
+
# mol/m2 -> 10^15 molec/cm2
|
|
23
|
+
UNIT_CONVERSION = 6.02214076e23 * 1e-4 * 1e-15
|
|
24
|
+
|
|
25
|
+
VALUE_NAME = "nitrogendioxide_tropospheric_column"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TropomiNO2Reader(BaseReader):
|
|
29
|
+
"""Reader for TROPOMI L2 NO2 tropospheric column files."""
|
|
30
|
+
|
|
31
|
+
def read(
|
|
32
|
+
self,
|
|
33
|
+
data_dir: str,
|
|
34
|
+
date: str,
|
|
35
|
+
extent: tuple[float, float, float, float],
|
|
36
|
+
qa_threshold: float = 0.5,
|
|
37
|
+
) -> SwathData:
|
|
38
|
+
patterns = [
|
|
39
|
+
os.path.join(data_dir, f"S5P_*_L2__NO2____{date}*.nc"),
|
|
40
|
+
os.path.join(data_dir, f"S5P_*_L2__NO2____{date}*.nc.zip"),
|
|
41
|
+
]
|
|
42
|
+
files: list[str] = []
|
|
43
|
+
for p in patterns:
|
|
44
|
+
files.extend(glob.glob(p))
|
|
45
|
+
files = sorted(set(files))
|
|
46
|
+
if not files:
|
|
47
|
+
raise FileNotFoundError(
|
|
48
|
+
f"未找到匹配文件:\n "
|
|
49
|
+
+ "\n ".join(patterns)
|
|
50
|
+
+ "\n请检查 --data-dir 和 --date 参数。"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
54
|
+
all_lon, all_lat, all_no2 = [], [], []
|
|
55
|
+
broken = 0
|
|
56
|
+
broken_examples: list[str] = []
|
|
57
|
+
|
|
58
|
+
for f in track(files, description="[cyan]读取轨道数据..."):
|
|
59
|
+
try:
|
|
60
|
+
lon, lat, no2, qa = read_tropomi_product_arrays(f, VALUE_NAME)
|
|
61
|
+
except (OSError, ValueError, KeyError) as e:
|
|
62
|
+
broken += 1
|
|
63
|
+
if len(broken_examples) < 3:
|
|
64
|
+
broken_examples.append(
|
|
65
|
+
f"{os.path.basename(f)}: {str(e).splitlines()[0]}"
|
|
66
|
+
)
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
mask = (
|
|
70
|
+
(qa > qa_threshold)
|
|
71
|
+
& (lon >= lon_min)
|
|
72
|
+
& (lon <= lon_max)
|
|
73
|
+
& (lat >= lat_min)
|
|
74
|
+
& (lat <= lat_max)
|
|
75
|
+
& np.isfinite(no2)
|
|
76
|
+
& (no2 < 1e30)
|
|
77
|
+
)
|
|
78
|
+
if mask.sum() > 0:
|
|
79
|
+
all_lon.append(lon[mask])
|
|
80
|
+
all_lat.append(lat[mask])
|
|
81
|
+
all_no2.append(no2[mask] * UNIT_CONVERSION)
|
|
82
|
+
|
|
83
|
+
if not all_lon:
|
|
84
|
+
broken_msg = (
|
|
85
|
+
"\n损坏/不可读示例:\n " + "\n ".join(broken_examples)
|
|
86
|
+
if broken_examples
|
|
87
|
+
else ""
|
|
88
|
+
)
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"{date} 当天无有效数据覆盖目标区域 {extent}。\n"
|
|
91
|
+
f"共扫描 {len(files)} 个文件,损坏 {broken} 个。"
|
|
92
|
+
f"{broken_msg}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return SwathData(
|
|
96
|
+
lon=np.concatenate(all_lon),
|
|
97
|
+
lat=np.concatenate(all_lat),
|
|
98
|
+
values=np.concatenate(all_no2),
|
|
99
|
+
n_pixels=sum(len(a) for a in all_lon),
|
|
100
|
+
n_files=len(files),
|
|
101
|
+
n_broken=broken,
|
|
102
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""TROPOMI O3 reader.
|
|
2
|
+
|
|
3
|
+
Reads S5P_*_L2__O3_____ files, applies QA filtering, spatial subsetting,
|
|
4
|
+
and unit conversion to DU (Dobson Units). Supports both flattened NRT-style
|
|
5
|
+
variables and official PRODUCT-group files.
|
|
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
|
+
# mol/m2 -> DU
|
|
23
|
+
# 1 DU = 2.6867e16 molec/cm2
|
|
24
|
+
# mol/m2 * Avogadro * 1e-4 (m2->cm2) / 2.6867e16 = DU
|
|
25
|
+
UNIT_CONVERSION = 6.02214076e23 * 1e-4 / 2.6867e16
|
|
26
|
+
|
|
27
|
+
VALUE_NAME = "ozone_total_vertical_column"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TropomiO3Reader(BaseReader):
|
|
31
|
+
"""Reader for TROPOMI L2 O3 total column files."""
|
|
32
|
+
|
|
33
|
+
def read(
|
|
34
|
+
self,
|
|
35
|
+
data_dir: str,
|
|
36
|
+
date: str,
|
|
37
|
+
extent: tuple[float, float, float, float],
|
|
38
|
+
qa_threshold: float = 0.5,
|
|
39
|
+
) -> SwathData:
|
|
40
|
+
patterns = [
|
|
41
|
+
os.path.join(data_dir, f"S5P_*_L2__O3_____{date}*.nc"),
|
|
42
|
+
os.path.join(data_dir, f"S5P_*_L2__O3_____{date}*.nc.zip"),
|
|
43
|
+
]
|
|
44
|
+
files: list[str] = []
|
|
45
|
+
for p in patterns:
|
|
46
|
+
files.extend(glob.glob(p))
|
|
47
|
+
files = sorted(set(files))
|
|
48
|
+
if not files:
|
|
49
|
+
raise FileNotFoundError(
|
|
50
|
+
f"未找到匹配文件:\n "
|
|
51
|
+
+ "\n ".join(patterns)
|
|
52
|
+
+ "\n请检查 --data-dir 和 --date 参数。"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
56
|
+
all_lon, all_lat, all_o3 = [], [], []
|
|
57
|
+
broken = 0
|
|
58
|
+
broken_examples: list[str] = []
|
|
59
|
+
|
|
60
|
+
for f in track(files, description="[cyan]读取轨道数据..."):
|
|
61
|
+
try:
|
|
62
|
+
lon, lat, o3, qa = read_tropomi_product_arrays(f, VALUE_NAME)
|
|
63
|
+
except (OSError, ValueError, KeyError) as e:
|
|
64
|
+
broken += 1
|
|
65
|
+
if len(broken_examples) < 3:
|
|
66
|
+
broken_examples.append(
|
|
67
|
+
f"{os.path.basename(f)}: {str(e).splitlines()[0]}"
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
mask = (
|
|
72
|
+
(qa > qa_threshold)
|
|
73
|
+
& (lon >= lon_min)
|
|
74
|
+
& (lon <= lon_max)
|
|
75
|
+
& (lat >= lat_min)
|
|
76
|
+
& (lat <= lat_max)
|
|
77
|
+
& np.isfinite(o3)
|
|
78
|
+
& (o3 < 1e30)
|
|
79
|
+
)
|
|
80
|
+
if mask.sum() > 0:
|
|
81
|
+
all_lon.append(lon[mask])
|
|
82
|
+
all_lat.append(lat[mask])
|
|
83
|
+
all_o3.append(o3[mask] * UNIT_CONVERSION)
|
|
84
|
+
|
|
85
|
+
if not all_lon:
|
|
86
|
+
broken_msg = (
|
|
87
|
+
"\n损坏/不可读示例:\n " + "\n ".join(broken_examples)
|
|
88
|
+
if broken_examples
|
|
89
|
+
else ""
|
|
90
|
+
)
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{date} 当天无有效数据覆盖目标区域 {extent}。\n"
|
|
93
|
+
f"共扫描 {len(files)} 个文件,损坏 {broken} 个。"
|
|
94
|
+
f"{broken_msg}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return SwathData(
|
|
98
|
+
lon=np.concatenate(all_lon),
|
|
99
|
+
lat=np.concatenate(all_lat),
|
|
100
|
+
values=np.concatenate(all_o3),
|
|
101
|
+
n_pixels=sum(len(a) for a in all_lon),
|
|
102
|
+
n_files=len(files),
|
|
103
|
+
n_broken=broken,
|
|
104
|
+
)
|