rsplot 0.3.1.dev3__py3-none-any.whl → 0.3.2.dev4__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/cli.py +48 -10
- rsplot/commands/common.py +64 -1
- rsplot/commands/forecast.py +24 -5
- rsplot/commands/overlay.py +172 -43
- rsplot/commands/raster.py +168 -44
- rsplot/commands/recent.py +29 -7
- rsplot/commands/reconstruct.py +27 -5
- rsplot/config.py +3 -0
- rsplot/geo/boundaries.py +105 -2
- rsplot/geo/footprints.py +202 -0
- rsplot/geo/processing.py +82 -0
- rsplot/plotting/map_frame.py +120 -4
- rsplot/plotting/overlay.py +5 -0
- rsplot/plotting/raster.py +5 -0
- rsplot/readers/__init__.py +38 -4
- rsplot/readers/base.py +16 -1
- rsplot/readers/gems.py +426 -0
- rsplot/recent.py +59 -16
- rsplot/result_builders/common.py +81 -0
- rsplot/result_builders/forecast.py +7 -0
- rsplot/result_builders/overlay.py +32 -2
- rsplot/result_builders/raster.py +32 -0
- rsplot/result_builders/reconstruct.py +7 -0
- rsplot/temporal.py +139 -16
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/METADATA +215 -1
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/RECORD +29 -27
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/WHEEL +0 -0
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/entry_points.txt +0 -0
- {rsplot-0.3.1.dev3.dist-info → rsplot-0.3.2.dev4.dist-info}/licenses/LICENSE +0 -0
rsplot/readers/gems.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""GEMS L2 scan readers; UTC archive names and China-time requests.
|
|
2
|
+
|
|
3
|
+
Daily ``read`` returns concatenated samples for BaseReader compatibility.
|
|
4
|
+
Scientific daily means use ``iter_scans`` and equal-scan grid averaging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
from zoneinfo import ZoneInfo
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from netCDF4 import Dataset
|
|
19
|
+
|
|
20
|
+
from rsplot.readers.base import BaseReader, SwathData
|
|
21
|
+
|
|
22
|
+
# Numerical analysis spacing, not native satellite resolution.
|
|
23
|
+
GEMS_ANALYSIS_RESOLUTION = 0.05
|
|
24
|
+
|
|
25
|
+
CHINA_TIME = ZoneInfo("Asia/Shanghai")
|
|
26
|
+
EPOCH = datetime(2000, 1, 1, 12, tzinfo=timezone.utc)
|
|
27
|
+
FILE_RE = re.compile(r"^GK2_GEMS_L2_(\d{8})_(\d{4})_(NO2|HCHO|O3P)_.+\.nc$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_gems_time(value: str) -> datetime:
|
|
31
|
+
"""Parse an exact nominal scan time in Asia/Shanghai."""
|
|
32
|
+
if not value.isdigit() or len(value) != 12:
|
|
33
|
+
raise ValueError("GEMS 扫描时间应为北京时间 YYYYMMDDHHMM。")
|
|
34
|
+
try:
|
|
35
|
+
return datetime.strptime(value, "%Y%m%d%H%M").replace(
|
|
36
|
+
tzinfo=CHINA_TIME
|
|
37
|
+
)
|
|
38
|
+
except ValueError as error:
|
|
39
|
+
raise ValueError(f"无效 GEMS 扫描时间: {value}") from error
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def match_station_hour(
|
|
43
|
+
scan: str, override: str | None = None
|
|
44
|
+
) -> tuple[str, dict[str, Any]]:
|
|
45
|
+
"""Match a scan to an hour, or pair a daily mean with an explicit hour."""
|
|
46
|
+
from rsplot.temporal import adapt_hourly_time, parse_yyyymmdd
|
|
47
|
+
|
|
48
|
+
if len(scan) == 8:
|
|
49
|
+
parse_yyyymmdd(scan, "GEMS 日均日期")
|
|
50
|
+
if override is None:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"GEMS 日均叠加需通过 --station-datetime YYYYMMDDHH "
|
|
53
|
+
"显式指定站点时次。"
|
|
54
|
+
)
|
|
55
|
+
hour = adapt_hourly_time(override).input_time.strftime("%Y%m%d%H")
|
|
56
|
+
return hour, {
|
|
57
|
+
"satellite_date": scan,
|
|
58
|
+
"satellite_aggregation": "daily_mean",
|
|
59
|
+
"station_datetime": hour,
|
|
60
|
+
"timezone": "Asia/Shanghai",
|
|
61
|
+
"difference_minutes": None,
|
|
62
|
+
"manual_override": True,
|
|
63
|
+
"reference": "daily_mean_with_explicit_station_hour",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
time = parse_gems_time(scan)
|
|
67
|
+
if override is None:
|
|
68
|
+
station = (time + timedelta(minutes=30)).replace(minute=0)
|
|
69
|
+
else:
|
|
70
|
+
station = adapt_hourly_time(override).input_time
|
|
71
|
+
hour = station.strftime("%Y%m%d%H")
|
|
72
|
+
return hour, {
|
|
73
|
+
"scan_datetime": scan,
|
|
74
|
+
"station_datetime": hour,
|
|
75
|
+
"timezone": "Asia/Shanghai",
|
|
76
|
+
"difference_minutes": (station - time).total_seconds() / 60,
|
|
77
|
+
"manual_override": override is not None,
|
|
78
|
+
"reference": "nominal_scan_time",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class GemsSpec:
|
|
84
|
+
file_product: str
|
|
85
|
+
value: str
|
|
86
|
+
cloud: str
|
|
87
|
+
scale: float = 1e-15
|
|
88
|
+
profile: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GemsReader(BaseReader):
|
|
92
|
+
"""Read required fields, filter each scan, and retain provenance."""
|
|
93
|
+
|
|
94
|
+
spec: ClassVar[GemsSpec]
|
|
95
|
+
uses_footprints = True
|
|
96
|
+
cloud_fraction_max: float = 0.5
|
|
97
|
+
|
|
98
|
+
def discover(
|
|
99
|
+
self, data_dir: str, request: str
|
|
100
|
+
) -> list[tuple[Path, datetime]]:
|
|
101
|
+
exact = len(request) == 12
|
|
102
|
+
if exact:
|
|
103
|
+
start = parse_gems_time(request).replace(hour=0, minute=0)
|
|
104
|
+
else:
|
|
105
|
+
if len(request) != 8 or not request.isdigit():
|
|
106
|
+
raise ValueError("GEMS 日期应为 YYYYMMDD 或 YYYYMMDDHHMM。")
|
|
107
|
+
try:
|
|
108
|
+
start = datetime.strptime(request, "%Y%m%d").replace(
|
|
109
|
+
tzinfo=CHINA_TIME
|
|
110
|
+
)
|
|
111
|
+
except ValueError as error:
|
|
112
|
+
raise ValueError(f"无效 GEMS 日期: {request}") from error
|
|
113
|
+
end = start + timedelta(days=1)
|
|
114
|
+
utc_days = {
|
|
115
|
+
start.astimezone(timezone.utc).date(),
|
|
116
|
+
(end - timedelta(microseconds=1)).astimezone(timezone.utc).date(),
|
|
117
|
+
}
|
|
118
|
+
files = []
|
|
119
|
+
for day in sorted(utc_days):
|
|
120
|
+
folder = Path(data_dir) / day.strftime("%Y%m") / day.strftime("%d")
|
|
121
|
+
for path in sorted(folder.glob("GK2_GEMS_L2_*.nc")):
|
|
122
|
+
match = FILE_RE.match(path.name)
|
|
123
|
+
if not match or match[3] != self.spec.file_product:
|
|
124
|
+
continue
|
|
125
|
+
nominal = datetime.strptime(
|
|
126
|
+
match[1] + match[2], "%Y%m%d%H%M"
|
|
127
|
+
).replace(tzinfo=timezone.utc)
|
|
128
|
+
if start <= nominal < end:
|
|
129
|
+
files.append((path, nominal))
|
|
130
|
+
files.sort(key=lambda item: (item[1], str(item[0])))
|
|
131
|
+
selected = (
|
|
132
|
+
[item for item in files if item[1] == parse_gems_time(request)]
|
|
133
|
+
if exact
|
|
134
|
+
else files
|
|
135
|
+
)
|
|
136
|
+
if not selected:
|
|
137
|
+
available = sorted(
|
|
138
|
+
{
|
|
139
|
+
time.astimezone(CHINA_TIME).strftime("%Y%m%d%H%M")
|
|
140
|
+
for _, time in files
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
raise FileNotFoundError(
|
|
144
|
+
f"未找到 GEMS {request};当日可用扫描(北京时间): "
|
|
145
|
+
+ (", ".join(available) or "无")
|
|
146
|
+
)
|
|
147
|
+
times = [time for _, time in selected]
|
|
148
|
+
if len(set(times)) != len(times):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
"GEMS 同一标称时次存在多个文件,请使用仅含一个版本的数据目录。"
|
|
151
|
+
)
|
|
152
|
+
return selected
|
|
153
|
+
|
|
154
|
+
def iter_scans(
|
|
155
|
+
self,
|
|
156
|
+
data_dir: str,
|
|
157
|
+
date: str,
|
|
158
|
+
extent: tuple[float, float, float, float],
|
|
159
|
+
qa_threshold: float = 0.5,
|
|
160
|
+
) -> Iterator[SwathData]:
|
|
161
|
+
"""Yield empty diagnostic batches for unreadable files as well."""
|
|
162
|
+
for path, nominal in self.discover(data_dir, date):
|
|
163
|
+
try:
|
|
164
|
+
yield self.read_file(path, nominal, extent)
|
|
165
|
+
except (
|
|
166
|
+
OSError,
|
|
167
|
+
ValueError,
|
|
168
|
+
KeyError,
|
|
169
|
+
IndexError,
|
|
170
|
+
AttributeError,
|
|
171
|
+
RuntimeError,
|
|
172
|
+
) as error:
|
|
173
|
+
empty = np.empty(0, dtype=float)
|
|
174
|
+
yield SwathData(
|
|
175
|
+
empty,
|
|
176
|
+
empty,
|
|
177
|
+
empty,
|
|
178
|
+
0,
|
|
179
|
+
1,
|
|
180
|
+
1,
|
|
181
|
+
{
|
|
182
|
+
"file": str(path),
|
|
183
|
+
"nominal_utc": nominal.isoformat(),
|
|
184
|
+
"nominal_local": nominal.astimezone(
|
|
185
|
+
CHINA_TIME
|
|
186
|
+
).isoformat(),
|
|
187
|
+
"error": str(error).splitlines()[0],
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def read(
|
|
192
|
+
self,
|
|
193
|
+
data_dir: str,
|
|
194
|
+
date: str,
|
|
195
|
+
extent: tuple[float, float, float, float],
|
|
196
|
+
qa_threshold: float = 0.5,
|
|
197
|
+
) -> SwathData:
|
|
198
|
+
scans = list(self.iter_scans(data_dir, date, extent, qa_threshold))
|
|
199
|
+
valid = [scan for scan in scans if scan.n_pixels]
|
|
200
|
+
if not valid:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"GEMS {date} 区域内无有效像元: "
|
|
203
|
+
+ str([scan.metadata for scan in scans])
|
|
204
|
+
)
|
|
205
|
+
return SwathData(
|
|
206
|
+
np.concatenate([scan.lon for scan in valid]),
|
|
207
|
+
np.concatenate([scan.lat for scan in valid]),
|
|
208
|
+
np.concatenate([scan.values for scan in valid]),
|
|
209
|
+
sum(scan.n_pixels for scan in scans),
|
|
210
|
+
len(scans),
|
|
211
|
+
sum(scan.n_broken for scan in scans),
|
|
212
|
+
{"scans": [scan.metadata for scan in scans]},
|
|
213
|
+
np.concatenate([scan.corner_lon for scan in valid]),
|
|
214
|
+
np.concatenate([scan.corner_lat for scan in valid]),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def read_file(
|
|
218
|
+
self,
|
|
219
|
+
path: Path,
|
|
220
|
+
nominal: datetime,
|
|
221
|
+
extent: tuple[float, float, float, float],
|
|
222
|
+
) -> SwathData:
|
|
223
|
+
spec = self.spec
|
|
224
|
+
geo = "geolocation" if spec.profile else "Geolocation Fields"
|
|
225
|
+
data = "product" if spec.profile else "Data Fields"
|
|
226
|
+
names = (
|
|
227
|
+
(
|
|
228
|
+
"longitude",
|
|
229
|
+
"latitude",
|
|
230
|
+
"solar_zenith_angle",
|
|
231
|
+
"viewing_zenith_angle",
|
|
232
|
+
)
|
|
233
|
+
if spec.profile
|
|
234
|
+
else (
|
|
235
|
+
"Longitude",
|
|
236
|
+
"Latitude",
|
|
237
|
+
"SolarZenithAngle",
|
|
238
|
+
"ViewingZenithAngle",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
flag_path = data + (
|
|
242
|
+
"/final_algorithm_flags"
|
|
243
|
+
if spec.profile
|
|
244
|
+
else "/FinalAlgorithmFlags"
|
|
245
|
+
)
|
|
246
|
+
with Dataset(path) as ds:
|
|
247
|
+
|
|
248
|
+
def array(name: str) -> np.ndarray:
|
|
249
|
+
return np.ma.asarray(ds[name][:], dtype=float).filled(np.nan)
|
|
250
|
+
|
|
251
|
+
lon, lat, sza, vza = [array(f"{geo}/{name}") for name in names]
|
|
252
|
+
value_var = ds[f"{data}/{spec.value}"]
|
|
253
|
+
units = value_var.getncattr("units")
|
|
254
|
+
expected = "DU" if spec.profile else "molecules cm-2"
|
|
255
|
+
if units != expected:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"{spec.value} 单位不匹配: {units!r},预期 {expected}"
|
|
258
|
+
)
|
|
259
|
+
value = array(f"{data}/{spec.value}")
|
|
260
|
+
flags = array(flag_path)
|
|
261
|
+
cloud = array(spec.cloud)
|
|
262
|
+
if value.ndim != 2 or any(
|
|
263
|
+
a.shape != value.shape
|
|
264
|
+
for a in (lon, lat, sza, vza, flags, cloud)
|
|
265
|
+
):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"GEMS 经纬度、数值和质量变量必须为同形状二维数组。"
|
|
268
|
+
)
|
|
269
|
+
from rsplot.geo.footprints import (
|
|
270
|
+
inferred_corner_grid,
|
|
271
|
+
pixel_corners,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
valid_geo = (
|
|
275
|
+
np.isfinite(lon)
|
|
276
|
+
& np.isfinite(lat)
|
|
277
|
+
& (np.abs(lon) <= 180)
|
|
278
|
+
& (np.abs(lat) <= 90)
|
|
279
|
+
)
|
|
280
|
+
lon = np.where(valid_geo, lon, np.nan)
|
|
281
|
+
lat = np.where(valid_geo, lat, np.nan)
|
|
282
|
+
if (
|
|
283
|
+
"CornerLongitude" in ds[geo].variables
|
|
284
|
+
and "CornerLatitude" in ds[geo].variables
|
|
285
|
+
):
|
|
286
|
+
corner_lon = array(f"{geo}/CornerLongitude")
|
|
287
|
+
corner_lat = array(f"{geo}/CornerLatitude")
|
|
288
|
+
footprint_source = "file_corner_coordinates"
|
|
289
|
+
else:
|
|
290
|
+
corner_lon = inferred_corner_grid(lon)
|
|
291
|
+
corner_lat = inferred_corner_grid(lat)
|
|
292
|
+
footprint_source = "estimated_from_unfiltered_native_centers"
|
|
293
|
+
expected_shape = (value.shape[0] + 1, value.shape[1] + 1)
|
|
294
|
+
if (
|
|
295
|
+
corner_lon.shape != expected_shape
|
|
296
|
+
or corner_lat.shape != expected_shape
|
|
297
|
+
):
|
|
298
|
+
raise ValueError("GEMS 像元角点网格形状不匹配。")
|
|
299
|
+
corner_lon, corner_lat = (
|
|
300
|
+
pixel_corners(corner_lon),
|
|
301
|
+
pixel_corners(corner_lat),
|
|
302
|
+
)
|
|
303
|
+
footprint_valid = (
|
|
304
|
+
np.isfinite(corner_lon).all(axis=-1)
|
|
305
|
+
& np.isfinite(corner_lat).all(axis=-1)
|
|
306
|
+
& (np.abs(corner_lon) <= 180).all(axis=-1)
|
|
307
|
+
& (np.abs(corner_lat) <= 90).all(axis=-1)
|
|
308
|
+
)
|
|
309
|
+
west, east, south, north = extent
|
|
310
|
+
mask = (
|
|
311
|
+
np.isfinite(value)
|
|
312
|
+
& (np.abs(value) < 1e29)
|
|
313
|
+
& np.isfinite(lon)
|
|
314
|
+
& np.isfinite(lat)
|
|
315
|
+
& footprint_valid
|
|
316
|
+
& (corner_lon.max(axis=-1) > west)
|
|
317
|
+
& (corner_lon.min(axis=-1) < east)
|
|
318
|
+
& (corner_lat.max(axis=-1) > south)
|
|
319
|
+
& (corner_lat.min(axis=-1) < north)
|
|
320
|
+
& (np.abs(lon) <= 180)
|
|
321
|
+
& (np.abs(lat) <= 90)
|
|
322
|
+
& (flags == 0)
|
|
323
|
+
& np.isfinite(sza)
|
|
324
|
+
& (sza >= 0)
|
|
325
|
+
& (sza < 70)
|
|
326
|
+
& np.isfinite(vza)
|
|
327
|
+
& (vza >= 0)
|
|
328
|
+
& (vza < 70)
|
|
329
|
+
& np.isfinite(cloud)
|
|
330
|
+
& (cloud >= 0)
|
|
331
|
+
& (cloud <= self.cloud_fraction_max)
|
|
332
|
+
)
|
|
333
|
+
settings = (
|
|
334
|
+
ds if spec.profile else ds["METADATA/ALGORITHM_SETTINGS"]
|
|
335
|
+
)
|
|
336
|
+
version = (
|
|
337
|
+
str(settings.getncattr("product_version"))
|
|
338
|
+
if "product_version" in settings.ncattrs()
|
|
339
|
+
else None
|
|
340
|
+
)
|
|
341
|
+
time_name = f"{geo}/" + ("time" if spec.profile else "Time")
|
|
342
|
+
time = array(time_name)
|
|
343
|
+
time_units = ds[time_name].getncattr("units")
|
|
344
|
+
# NO2 v4 omits the epoch; it uses the GEMS mission epoch.
|
|
345
|
+
known_units = (
|
|
346
|
+
"second",
|
|
347
|
+
"Seconds since 2000-01-01 12 UTC",
|
|
348
|
+
"seconds since 2000-01-01 12:00:00 UTC",
|
|
349
|
+
)
|
|
350
|
+
if time_units not in known_units:
|
|
351
|
+
raise ValueError(f"未知 GEMS 时间单位: {time_units!r}")
|
|
352
|
+
time = time[np.isfinite(time) & (np.abs(time) < 1e12)]
|
|
353
|
+
meta = {
|
|
354
|
+
"file": str(path),
|
|
355
|
+
"product_version": version,
|
|
356
|
+
"variable": f"{data}/{spec.value}",
|
|
357
|
+
"source_unit": units,
|
|
358
|
+
"unit_scale": spec.scale,
|
|
359
|
+
"nominal_utc": nominal.isoformat(),
|
|
360
|
+
"nominal_local": nominal.astimezone(CHINA_TIME).isoformat(),
|
|
361
|
+
"observation_start_utc": (
|
|
362
|
+
(EPOCH + timedelta(seconds=float(time.min()))).isoformat()
|
|
363
|
+
if time.size
|
|
364
|
+
else None
|
|
365
|
+
),
|
|
366
|
+
"observation_end_utc": (
|
|
367
|
+
(EPOCH + timedelta(seconds=float(time.max()))).isoformat()
|
|
368
|
+
if time.size
|
|
369
|
+
else None
|
|
370
|
+
),
|
|
371
|
+
"time_epoch": EPOCH.isoformat(),
|
|
372
|
+
"quality": {
|
|
373
|
+
"flag_variable": flag_path,
|
|
374
|
+
"accepted_values": [0],
|
|
375
|
+
"solar_zenith_max_exclusive": 70,
|
|
376
|
+
"viewing_zenith_max_exclusive": 70,
|
|
377
|
+
"cloud_variable": spec.cloud,
|
|
378
|
+
"cloud_fraction_max": self.cloud_fraction_max,
|
|
379
|
+
},
|
|
380
|
+
"n_pixels": int(mask.sum()),
|
|
381
|
+
"footprint_source": footprint_source,
|
|
382
|
+
"footprint_geometry": "native_quadrilateral",
|
|
383
|
+
}
|
|
384
|
+
if not mask.any():
|
|
385
|
+
meta["empty_reason"] = "区域内无通过质量筛选的像元"
|
|
386
|
+
return SwathData(
|
|
387
|
+
lon[mask],
|
|
388
|
+
lat[mask],
|
|
389
|
+
value[mask] * spec.scale,
|
|
390
|
+
int(mask.sum()),
|
|
391
|
+
1,
|
|
392
|
+
0,
|
|
393
|
+
meta,
|
|
394
|
+
corner_lon[mask],
|
|
395
|
+
corner_lat[mask],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class GemsNO2Reader(GemsReader):
|
|
400
|
+
spec = GemsSpec("NO2", "ColumnAmountNO2Trop", "Data Fields/CloudFraction")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class GemsHCHOReader(GemsReader):
|
|
404
|
+
spec = GemsSpec(
|
|
405
|
+
"HCHO", "ColumnAmount", "Data Fields/CloudRadianceFraction"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class GemsO3Reader(GemsReader):
|
|
410
|
+
spec = GemsSpec(
|
|
411
|
+
"O3P",
|
|
412
|
+
"total_ozone_column",
|
|
413
|
+
"support_data/effective_cloud_fraction",
|
|
414
|
+
scale=1.0,
|
|
415
|
+
profile=True,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class GemsO3PRReader(GemsReader):
|
|
420
|
+
spec = GemsSpec(
|
|
421
|
+
"O3P",
|
|
422
|
+
"troposphere_ozone_column",
|
|
423
|
+
"support_data/effective_cloud_fraction",
|
|
424
|
+
scale=1.0,
|
|
425
|
+
profile=True,
|
|
426
|
+
)
|
rsplot/recent.py
CHANGED
|
@@ -20,7 +20,11 @@ from rich.progress import track
|
|
|
20
20
|
|
|
21
21
|
from rsplot.config import get_exceedance_threshold
|
|
22
22
|
from rsplot.geo.gridding import grid_coverage_in_region
|
|
23
|
-
from rsplot.geo.processing import
|
|
23
|
+
from rsplot.geo.processing import (
|
|
24
|
+
buffered_extent,
|
|
25
|
+
prepare_existing_grid,
|
|
26
|
+
prepare_swath_grid,
|
|
27
|
+
)
|
|
24
28
|
from rsplot.readers import ProductInfo
|
|
25
29
|
from rsplot.readers.guokong import (
|
|
26
30
|
mask_stations_to_region,
|
|
@@ -87,16 +91,47 @@ def summarize_recent_raster(
|
|
|
87
91
|
for d in track(
|
|
88
92
|
dates, description=f"[cyan]统计 {product.upper()} 最近数据..."
|
|
89
93
|
):
|
|
94
|
+
meta: dict[str, Any] = {}
|
|
90
95
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if prod_info.sensor == "gems":
|
|
97
|
+
from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
|
|
98
|
+
from rsplot.temporal import read_scan_mean
|
|
99
|
+
|
|
100
|
+
batch = read_scan_mean(
|
|
101
|
+
reader=reader,
|
|
102
|
+
data_dir=data_dir,
|
|
103
|
+
request=d,
|
|
104
|
+
extent=read_extent,
|
|
105
|
+
res=GEMS_ANALYSIS_RESOLUTION,
|
|
106
|
+
)
|
|
107
|
+
prepared = prepare_existing_grid(
|
|
108
|
+
batch.LON,
|
|
109
|
+
batch.LAT,
|
|
110
|
+
batch.grid,
|
|
111
|
+
region.geometry,
|
|
112
|
+
fill_gaps=False,
|
|
113
|
+
)
|
|
114
|
+
meta = {
|
|
115
|
+
"n_pixels": batch.n_pixels,
|
|
116
|
+
"n_files": batch.n_files,
|
|
117
|
+
"n_broken": batch.n_broken,
|
|
118
|
+
**batch.metadata,
|
|
119
|
+
}
|
|
120
|
+
else:
|
|
121
|
+
swath = reader.read(data_dir, d, read_extent, qa_threshold)
|
|
122
|
+
prepared = prepare_swath_grid(
|
|
123
|
+
swath.lon,
|
|
124
|
+
swath.lat,
|
|
125
|
+
swath.values,
|
|
126
|
+
read_extent,
|
|
127
|
+
res,
|
|
128
|
+
region.geometry,
|
|
129
|
+
)
|
|
130
|
+
meta = {
|
|
131
|
+
"n_pixels": int(swath.n_pixels),
|
|
132
|
+
"n_files": int(swath.n_files),
|
|
133
|
+
"n_broken": int(swath.n_broken),
|
|
134
|
+
}
|
|
100
135
|
stats = _grid_stats(
|
|
101
136
|
prepared.lon,
|
|
102
137
|
prepared.lat,
|
|
@@ -111,11 +146,7 @@ def summarize_recent_raster(
|
|
|
111
146
|
ok=True,
|
|
112
147
|
value=stats.get("mean"),
|
|
113
148
|
stats=stats,
|
|
114
|
-
meta=
|
|
115
|
-
"n_pixels": int(swath.n_pixels),
|
|
116
|
-
"n_files": int(swath.n_files),
|
|
117
|
-
"n_broken": int(swath.n_broken),
|
|
118
|
-
},
|
|
149
|
+
meta=meta,
|
|
119
150
|
)
|
|
120
151
|
)
|
|
121
152
|
except (FileNotFoundError, ValueError) as e:
|
|
@@ -125,7 +156,7 @@ def summarize_recent_raster(
|
|
|
125
156
|
ok=False,
|
|
126
157
|
value=None,
|
|
127
158
|
stats={},
|
|
128
|
-
meta=
|
|
159
|
+
meta=meta,
|
|
129
160
|
error=str(e).splitlines()[0],
|
|
130
161
|
)
|
|
131
162
|
)
|
|
@@ -207,6 +238,8 @@ def build_recent_result(
|
|
|
207
238
|
product: str | None = None,
|
|
208
239
|
variable: str | None = None,
|
|
209
240
|
unit: str | None = None,
|
|
241
|
+
sensor: str | None = None,
|
|
242
|
+
quantity: str | None = None,
|
|
210
243
|
) -> dict[str, Any]:
|
|
211
244
|
"""Build the JSON payload for a recent run."""
|
|
212
245
|
daily_rows = [_daily_to_json(row) for row in daily]
|
|
@@ -240,6 +273,16 @@ def build_recent_result(
|
|
|
240
273
|
"trend": _trend_summary(daily),
|
|
241
274
|
"warnings": _warnings(daily),
|
|
242
275
|
}
|
|
276
|
+
if source == "raster" and sensor == "gems":
|
|
277
|
+
result["sensor"] = sensor
|
|
278
|
+
result["quantity"] = quantity
|
|
279
|
+
result["statistics_basis"] = "unfilled_unsmoothed_grid"
|
|
280
|
+
result["input_timezone"] = "Asia/Shanghai"
|
|
281
|
+
result["params"] = {
|
|
282
|
+
key: value
|
|
283
|
+
for key, value in params.items()
|
|
284
|
+
if key != "qa_threshold"
|
|
285
|
+
}
|
|
243
286
|
if product is not None:
|
|
244
287
|
result["product"] = product
|
|
245
288
|
if variable is not None:
|
rsplot/result_builders/common.py
CHANGED
|
@@ -25,6 +25,58 @@ def serialize_region(region: RegionInfo) -> dict[str, Any]:
|
|
|
25
25
|
result["administrative_level"] = region.administrative_level
|
|
26
26
|
if region.subdivision_level is not None:
|
|
27
27
|
result["subdivision_level"] = region.subdivision_level
|
|
28
|
+
if region.members:
|
|
29
|
+
result["members"] = [
|
|
30
|
+
serialize_member(member) for member in region.members
|
|
31
|
+
]
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def serialize_member(member: RegionInfo) -> dict[str, Any]:
|
|
36
|
+
"""Identify a selected member with its canonical, qualified name."""
|
|
37
|
+
result = serialize_region(member)
|
|
38
|
+
result["name"] = member.qualified_name or member.name
|
|
39
|
+
result.setdefault(
|
|
40
|
+
"administrative_level", member.administrative_level or member.level
|
|
41
|
+
)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def member_basic_stats(values: np.ndarray) -> dict[str, Any]:
|
|
46
|
+
"""Explicit null summaries retain members with no valid observations."""
|
|
47
|
+
result = basic_stats(values)
|
|
48
|
+
if result:
|
|
49
|
+
return result
|
|
50
|
+
return {
|
|
51
|
+
"n": 0,
|
|
52
|
+
**dict.fromkeys(
|
|
53
|
+
("min", "max", "mean", "median", "p10", "p25", "p75", "p90")
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def grid_member_stats(
|
|
59
|
+
lon: np.ndarray,
|
|
60
|
+
lat: np.ndarray,
|
|
61
|
+
grid: np.ndarray,
|
|
62
|
+
region: RegionInfo,
|
|
63
|
+
) -> list[dict[str, Any]]:
|
|
64
|
+
"""Select each member on the same statistical grid, never sum overlaps."""
|
|
65
|
+
from rsplot.geo.gridding import mask_to_region
|
|
66
|
+
|
|
67
|
+
result = []
|
|
68
|
+
for member in region.members:
|
|
69
|
+
selected = mask_to_region(lon, lat, grid, member.geometry)
|
|
70
|
+
stats = grid_stats(
|
|
71
|
+
lon,
|
|
72
|
+
lat,
|
|
73
|
+
selected,
|
|
74
|
+
member,
|
|
75
|
+
valid_key="valid_region_pixels",
|
|
76
|
+
total_key="total_region_pixels",
|
|
77
|
+
)
|
|
78
|
+
stats.update(member_basic_stats(selected))
|
|
79
|
+
result.append({"region": serialize_member(member), "stats": stats})
|
|
28
80
|
return result
|
|
29
81
|
|
|
30
82
|
|
|
@@ -136,3 +188,32 @@ def grid_stats(
|
|
|
136
188
|
total_key: total,
|
|
137
189
|
**basic_stats(grid),
|
|
138
190
|
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def grid_boundary_policy(
|
|
194
|
+
lon: np.ndarray,
|
|
195
|
+
lat: np.ndarray,
|
|
196
|
+
display_grid: np.ndarray,
|
|
197
|
+
region: RegionInfo,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
"""Describe distinct display and statistics spatial selection rules."""
|
|
200
|
+
from rsplot.geo.gridding import region_grid_mask
|
|
201
|
+
from rsplot.geo.processing import region_cell_mask
|
|
202
|
+
|
|
203
|
+
visible = np.isfinite(display_grid) & region_cell_mask(
|
|
204
|
+
lon, lat, region.geometry
|
|
205
|
+
)
|
|
206
|
+
inside = region_grid_mask(lon, lat, region.geometry)
|
|
207
|
+
return {
|
|
208
|
+
"statistics": {
|
|
209
|
+
"selection": "grid_center_strictly_inside",
|
|
210
|
+
"weighting": "equal_cells",
|
|
211
|
+
"coverage": "valid_center_count / total_center_count",
|
|
212
|
+
},
|
|
213
|
+
"display": {
|
|
214
|
+
"selection": "valid_cells_intersecting_region",
|
|
215
|
+
"clip": "administrative_geometry",
|
|
216
|
+
"n_valid_intersecting_cells": int(visible.sum()),
|
|
217
|
+
"n_cells_with_centers_outside": int((visible & ~inside).sum()),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
import numpy as np
|
|
8
8
|
|
|
9
9
|
from rsplot.result_builders.common import (
|
|
10
|
+
grid_boundary_policy,
|
|
10
11
|
grid_per_admin_stats,
|
|
11
12
|
serialize_region,
|
|
12
13
|
top_hotspots,
|
|
@@ -33,6 +34,7 @@ def build_forecast_result(
|
|
|
33
34
|
vmin: float,
|
|
34
35
|
vmax: float,
|
|
35
36
|
output: str,
|
|
37
|
+
display_grid: np.ndarray | None = None,
|
|
36
38
|
station_data: StationData | None = None,
|
|
37
39
|
station_datetime: str | None = None,
|
|
38
40
|
station_threshold: float | None = None,
|
|
@@ -100,6 +102,11 @@ def build_forecast_result(
|
|
|
100
102
|
station_block.pop(key, None)
|
|
101
103
|
result["station"] = station_block
|
|
102
104
|
|
|
105
|
+
if display_grid is not None:
|
|
106
|
+
result["boundary_policy"] = grid_boundary_policy(
|
|
107
|
+
LON, LAT, display_grid, region
|
|
108
|
+
)
|
|
109
|
+
|
|
103
110
|
sub_gdf = region.sub_boundary_gdf
|
|
104
111
|
if sub_gdf is not None and len(sub_gdf) > 0:
|
|
105
112
|
result["spatial_summary"] = grid_per_admin_stats(
|