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.
@@ -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
+ )