rsplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rsplot/__init__.py +3 -0
- rsplot/__main__.py +6 -0
- rsplot/cli.py +1100 -0
- rsplot/config.py +290 -0
- rsplot/fnr.py +384 -0
- rsplot/geo/__init__.py +0 -0
- rsplot/geo/boundaries.py +281 -0
- rsplot/geo/gridding.py +120 -0
- rsplot/plotting/__init__.py +1 -0
- rsplot/plotting/colormaps.py +67 -0
- rsplot/plotting/fnr.py +200 -0
- rsplot/plotting/overlay.py +241 -0
- rsplot/plotting/raster.py +222 -0
- rsplot/plotting/station.py +225 -0
- rsplot/plotting/styles.py +35 -0
- rsplot/readers/__init__.py +96 -0
- rsplot/readers/base.py +96 -0
- rsplot/readers/guokong.py +143 -0
- rsplot/readers/tropomi_hcho.py +126 -0
- rsplot/readers/tropomi_no2.py +102 -0
- rsplot/readers/tropomi_o3.py +104 -0
- rsplot/results.py +553 -0
- rsplot/tiles/__init__.py +1 -0
- rsplot/tiles/tianditu.py +32 -0
- rsplot-0.1.0.dist-info/METADATA +252 -0
- rsplot-0.1.0.dist-info/RECORD +29 -0
- rsplot-0.1.0.dist-info/WHEEL +4 -0
- rsplot-0.1.0.dist-info/entry_points.txt +2 -0
- rsplot-0.1.0.dist-info/licenses/LICENSE +201 -0
rsplot/results.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Structured result builder and emitter for agent-friendly output.
|
|
2
|
+
|
|
3
|
+
Every CLI command writes a sidecar JSON file next to the output image
|
|
4
|
+
({output}.json). The calling agent reads this file directly instead of
|
|
5
|
+
parsing stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import geopandas as gpd
|
|
18
|
+
|
|
19
|
+
from rsplot.fnr import FnrResult
|
|
20
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
21
|
+
from rsplot.readers import ProductInfo
|
|
22
|
+
from rsplot.readers.guokong import StationData
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Generic helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
def _round(x: float, n: int = 2) -> float:
|
|
29
|
+
"""Round, keeping NaN as None for JSON."""
|
|
30
|
+
if not np.isfinite(x):
|
|
31
|
+
return None # type: ignore[return-value]
|
|
32
|
+
return round(float(x), n)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _basic_stats(values: np.ndarray, n: int = 2) -> dict[str, float]:
|
|
36
|
+
"""min/max/mean/median + p10/p25/p75/p90 over finite values."""
|
|
37
|
+
finite = values[np.isfinite(values)]
|
|
38
|
+
if len(finite) == 0:
|
|
39
|
+
return {}
|
|
40
|
+
return {
|
|
41
|
+
"n": int(len(finite)),
|
|
42
|
+
"min": _round(np.min(finite), n),
|
|
43
|
+
"max": _round(np.max(finite), n),
|
|
44
|
+
"mean": _round(np.mean(finite), n),
|
|
45
|
+
"median": _round(np.median(finite), n),
|
|
46
|
+
"p10": _round(np.percentile(finite, 10), n),
|
|
47
|
+
"p25": _round(np.percentile(finite, 25), n),
|
|
48
|
+
"p75": _round(np.percentile(finite, 75), n),
|
|
49
|
+
"p90": _round(np.percentile(finite, 90), n),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Raster
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
def _grid_per_city_stats(
|
|
57
|
+
LON: np.ndarray,
|
|
58
|
+
LAT: np.ndarray,
|
|
59
|
+
grid: np.ndarray,
|
|
60
|
+
cities_gdf: gpd.GeoDataFrame,
|
|
61
|
+
) -> list[dict[str, Any]]:
|
|
62
|
+
"""Per-city aggregation on a gridded raster."""
|
|
63
|
+
from shapely.vectorized import contains
|
|
64
|
+
|
|
65
|
+
rows: list[dict[str, Any]] = []
|
|
66
|
+
for _, row in cities_gdf.iterrows():
|
|
67
|
+
mask = contains(row.geometry, LON, LAT)
|
|
68
|
+
sub = grid[mask]
|
|
69
|
+
finite = sub[np.isfinite(sub)]
|
|
70
|
+
if len(finite) == 0:
|
|
71
|
+
continue
|
|
72
|
+
rows.append(
|
|
73
|
+
{
|
|
74
|
+
"name": row["name"],
|
|
75
|
+
"n_pixels": int(len(finite)),
|
|
76
|
+
"mean": _round(np.mean(finite)),
|
|
77
|
+
"max": _round(np.max(finite)),
|
|
78
|
+
"min": _round(np.min(finite)),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
rows.sort(key=lambda r: (r["mean"] is None, -(r["mean"] or 0)))
|
|
82
|
+
return rows
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _top_hotspots(
|
|
86
|
+
LON: np.ndarray,
|
|
87
|
+
LAT: np.ndarray,
|
|
88
|
+
grid: np.ndarray,
|
|
89
|
+
cities_gdf: gpd.GeoDataFrame | None,
|
|
90
|
+
n: int = 5,
|
|
91
|
+
) -> list[dict[str, Any]]:
|
|
92
|
+
"""Top-N highest finite pixels, each tagged with its containing city."""
|
|
93
|
+
from shapely.geometry import Point
|
|
94
|
+
|
|
95
|
+
flat = grid.ravel()
|
|
96
|
+
lon_flat = LON.ravel()
|
|
97
|
+
lat_flat = LAT.ravel()
|
|
98
|
+
order = np.argsort(flat)[::-1]
|
|
99
|
+
|
|
100
|
+
hotspots: list[dict[str, Any]] = []
|
|
101
|
+
for i in order:
|
|
102
|
+
v = flat[i]
|
|
103
|
+
if not np.isfinite(v):
|
|
104
|
+
continue
|
|
105
|
+
lon, lat = float(lon_flat[i]), float(lat_flat[i])
|
|
106
|
+
nearest_city = None
|
|
107
|
+
if cities_gdf is not None and len(cities_gdf) > 0:
|
|
108
|
+
pt = Point(lon, lat)
|
|
109
|
+
hit = cities_gdf[cities_gdf.geometry.contains(pt)]
|
|
110
|
+
if len(hit) > 0:
|
|
111
|
+
nearest_city = hit.iloc[0]["name"]
|
|
112
|
+
else:
|
|
113
|
+
dists = cities_gdf.geometry.distance(pt)
|
|
114
|
+
nearest_city = cities_gdf.iloc[int(dists.argmin())]["name"]
|
|
115
|
+
hotspots.append(
|
|
116
|
+
{
|
|
117
|
+
"lon": round(lon, 3),
|
|
118
|
+
"lat": round(lat, 3),
|
|
119
|
+
"value": _round(v),
|
|
120
|
+
"city": nearest_city,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
if len(hotspots) >= n:
|
|
124
|
+
break
|
|
125
|
+
return hotspots
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_raster_result(
|
|
129
|
+
*,
|
|
130
|
+
region: RegionInfo,
|
|
131
|
+
product: str,
|
|
132
|
+
date: str,
|
|
133
|
+
prod_info: ProductInfo,
|
|
134
|
+
LON: np.ndarray,
|
|
135
|
+
LAT: np.ndarray,
|
|
136
|
+
grid: np.ndarray,
|
|
137
|
+
swath_meta: dict[str, Any],
|
|
138
|
+
vmin: float,
|
|
139
|
+
vmax: float,
|
|
140
|
+
output: str,
|
|
141
|
+
window: dict[str, Any] | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Build the JSON sidecar payload for a raster run.
|
|
144
|
+
|
|
145
|
+
``window`` is None for single-day runs and a dict (start/end/
|
|
146
|
+
n_days_requested/n_days_found/n_min/dates_failed/coverage_days) for
|
|
147
|
+
window-mean runs.
|
|
148
|
+
"""
|
|
149
|
+
finite = grid[np.isfinite(grid)]
|
|
150
|
+
total = grid.size
|
|
151
|
+
coverage_pct = round(100 * len(finite) / total, 1) if total else 0.0
|
|
152
|
+
|
|
153
|
+
result: dict[str, Any] = {
|
|
154
|
+
"command": "raster",
|
|
155
|
+
"image": output,
|
|
156
|
+
"region": {
|
|
157
|
+
"name": region.name,
|
|
158
|
+
"level": region.level,
|
|
159
|
+
"extent": [round(x, 3) for x in region.extent],
|
|
160
|
+
},
|
|
161
|
+
"product": product,
|
|
162
|
+
"date": date,
|
|
163
|
+
"unit": prod_info.colorbar_label,
|
|
164
|
+
"vrange": {"vmin": _round(vmin), "vmax": _round(vmax)},
|
|
165
|
+
"swath": swath_meta,
|
|
166
|
+
"stats": {
|
|
167
|
+
"grid_shape": list(grid.shape),
|
|
168
|
+
"coverage_pct": coverage_pct,
|
|
169
|
+
**_basic_stats(grid),
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
if window is not None:
|
|
173
|
+
result["window"] = window
|
|
174
|
+
|
|
175
|
+
sub_gdf = region.sub_boundary_gdf
|
|
176
|
+
if sub_gdf is not None and len(sub_gdf) > 0:
|
|
177
|
+
result["spatial_summary"] = _grid_per_city_stats(LON, LAT, grid, sub_gdf)
|
|
178
|
+
result["hotspots"] = _top_hotspots(LON, LAT, grid, sub_gdf, n=5)
|
|
179
|
+
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Station
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
def _attribute_stations_to_cities(
|
|
187
|
+
lon: np.ndarray, lat: np.ndarray, cities_gdf: gpd.GeoDataFrame
|
|
188
|
+
) -> np.ndarray:
|
|
189
|
+
"""Return city name for each (lon, lat); empty string if none contain it."""
|
|
190
|
+
from shapely.geometry import Point
|
|
191
|
+
|
|
192
|
+
sindex = cities_gdf.sindex
|
|
193
|
+
out = np.empty(len(lon), dtype=object)
|
|
194
|
+
for i, (x, y) in enumerate(zip(lon, lat)):
|
|
195
|
+
if not (np.isfinite(x) and np.isfinite(y)):
|
|
196
|
+
out[i] = ""
|
|
197
|
+
continue
|
|
198
|
+
pt = Point(float(x), float(y))
|
|
199
|
+
city = ""
|
|
200
|
+
for idx in sindex.intersection((x, y, x, y)):
|
|
201
|
+
if cities_gdf.iloc[idx].geometry.contains(pt):
|
|
202
|
+
city = cities_gdf.iloc[idx]["name"]
|
|
203
|
+
break
|
|
204
|
+
out[i] = city
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _aqi_distribution(vals: np.ndarray) -> dict[str, int]:
|
|
209
|
+
bounds = [0, 50, 100, 150, 200, 300, 500]
|
|
210
|
+
labels = ["优", "良", "轻度", "中度", "重度", "严重"]
|
|
211
|
+
dist: dict[str, int] = {}
|
|
212
|
+
for i, label in enumerate(labels):
|
|
213
|
+
lo, hi = bounds[i], bounds[i + 1]
|
|
214
|
+
if i == 0:
|
|
215
|
+
m = (vals >= lo) & (vals <= hi)
|
|
216
|
+
else:
|
|
217
|
+
m = (vals > lo) & (vals <= hi)
|
|
218
|
+
dist[label] = int(m.sum())
|
|
219
|
+
dist["爆表"] = int((vals > 500).sum())
|
|
220
|
+
return dist
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_station_result(
|
|
224
|
+
*,
|
|
225
|
+
region: RegionInfo,
|
|
226
|
+
data: StationData,
|
|
227
|
+
datetime_str: str,
|
|
228
|
+
threshold: float | None,
|
|
229
|
+
unit: str,
|
|
230
|
+
cities_gdf: gpd.GeoDataFrame | None,
|
|
231
|
+
output: str,
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
valid = np.isfinite(data.values)
|
|
234
|
+
vals = data.values[valid]
|
|
235
|
+
lons = data.lon[valid]
|
|
236
|
+
lats = data.lat[valid]
|
|
237
|
+
ids = np.asarray(data.id)[valid]
|
|
238
|
+
|
|
239
|
+
result: dict[str, Any] = {
|
|
240
|
+
"command": "station",
|
|
241
|
+
"image": output,
|
|
242
|
+
"region": {
|
|
243
|
+
"name": region.name,
|
|
244
|
+
"level": region.level,
|
|
245
|
+
"extent": [round(x, 3) for x in region.extent],
|
|
246
|
+
},
|
|
247
|
+
"datetime": datetime_str,
|
|
248
|
+
"variable": data.var_name,
|
|
249
|
+
"unit": unit or None,
|
|
250
|
+
"threshold": threshold,
|
|
251
|
+
"n_stations": int(data.n_stations),
|
|
252
|
+
"n_valid": int(data.n_valid),
|
|
253
|
+
"stats": _basic_stats(vals),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
station_cities = None
|
|
257
|
+
if cities_gdf is not None and len(cities_gdf) > 0 and len(vals) > 0:
|
|
258
|
+
station_cities = _attribute_stations_to_cities(lons, lats, cities_gdf)
|
|
259
|
+
|
|
260
|
+
# Exceedance summary
|
|
261
|
+
if threshold is not None and len(vals) > 0:
|
|
262
|
+
exceed_mask = vals > threshold
|
|
263
|
+
n_exceed = int(exceed_mask.sum())
|
|
264
|
+
top_idx = np.argsort(vals)[::-1]
|
|
265
|
+
top_stations: list[dict[str, Any]] = []
|
|
266
|
+
for i in top_idx:
|
|
267
|
+
if vals[i] <= threshold:
|
|
268
|
+
break
|
|
269
|
+
entry: dict[str, Any] = {
|
|
270
|
+
"id": str(ids[i]),
|
|
271
|
+
"value": _round(vals[i]),
|
|
272
|
+
"lon": round(float(lons[i]), 3),
|
|
273
|
+
"lat": round(float(lats[i]), 3),
|
|
274
|
+
}
|
|
275
|
+
if station_cities is not None:
|
|
276
|
+
entry["city"] = station_cities[i] or None
|
|
277
|
+
top_stations.append(entry)
|
|
278
|
+
if len(top_stations) >= 10:
|
|
279
|
+
break
|
|
280
|
+
result["exceedance"] = {
|
|
281
|
+
"count": n_exceed,
|
|
282
|
+
"rate_pct": round(100 * n_exceed / len(vals), 1),
|
|
283
|
+
"top_stations": top_stations,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# AQI discrete distribution
|
|
287
|
+
if data.var_name == "AQI" and len(vals) > 0:
|
|
288
|
+
result["aqi_distribution"] = _aqi_distribution(vals)
|
|
289
|
+
|
|
290
|
+
# Per-city ranking
|
|
291
|
+
if station_cities is not None and len(vals) > 0:
|
|
292
|
+
acc: dict[str, dict[str, Any]] = {}
|
|
293
|
+
for i in range(len(vals)):
|
|
294
|
+
c = station_cities[i]
|
|
295
|
+
if not c:
|
|
296
|
+
continue
|
|
297
|
+
s = acc.setdefault(c, {"values": [], "n_exceed": 0})
|
|
298
|
+
s["values"].append(float(vals[i]))
|
|
299
|
+
if threshold is not None and vals[i] > threshold:
|
|
300
|
+
s["n_exceed"] += 1
|
|
301
|
+
ranking = []
|
|
302
|
+
for city, s in acc.items():
|
|
303
|
+
arr = np.asarray(s["values"])
|
|
304
|
+
ranking.append(
|
|
305
|
+
{
|
|
306
|
+
"city": city,
|
|
307
|
+
"n_valid": len(arr),
|
|
308
|
+
"mean": _round(np.mean(arr)),
|
|
309
|
+
"max": _round(np.max(arr)),
|
|
310
|
+
"n_exceed": s["n_exceed"],
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
ranking.sort(key=lambda r: (r["mean"] is None, -(r["mean"] or 0)))
|
|
314
|
+
result["city_ranking"] = ranking
|
|
315
|
+
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Overlay (raster + station combined)
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
def build_overlay_result(
|
|
323
|
+
*,
|
|
324
|
+
region: RegionInfo,
|
|
325
|
+
product: str,
|
|
326
|
+
date: str,
|
|
327
|
+
prod_info: ProductInfo,
|
|
328
|
+
LON: np.ndarray,
|
|
329
|
+
LAT: np.ndarray,
|
|
330
|
+
grid: np.ndarray,
|
|
331
|
+
swath_meta: dict[str, Any],
|
|
332
|
+
raster_vmin: float,
|
|
333
|
+
raster_vmax: float,
|
|
334
|
+
data: StationData,
|
|
335
|
+
datetime_str: str,
|
|
336
|
+
threshold: float | None,
|
|
337
|
+
unit: str,
|
|
338
|
+
cities_gdf: gpd.GeoDataFrame | None,
|
|
339
|
+
output: str,
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
"""Combine raster + station results into a single JSON payload.
|
|
342
|
+
|
|
343
|
+
Nested under `raster` and `station` keys so agents can inspect either
|
|
344
|
+
layer independently. A shared `region`, `image`, and `datetime` sit
|
|
345
|
+
at the top level.
|
|
346
|
+
"""
|
|
347
|
+
raster_block = build_raster_result(
|
|
348
|
+
region=region,
|
|
349
|
+
product=product,
|
|
350
|
+
date=date,
|
|
351
|
+
prod_info=prod_info,
|
|
352
|
+
LON=LON,
|
|
353
|
+
LAT=LAT,
|
|
354
|
+
grid=grid,
|
|
355
|
+
swath_meta=swath_meta,
|
|
356
|
+
vmin=raster_vmin,
|
|
357
|
+
vmax=raster_vmax,
|
|
358
|
+
output=output,
|
|
359
|
+
)
|
|
360
|
+
station_block = build_station_result(
|
|
361
|
+
region=region,
|
|
362
|
+
data=data,
|
|
363
|
+
datetime_str=datetime_str,
|
|
364
|
+
threshold=threshold,
|
|
365
|
+
unit=unit,
|
|
366
|
+
cities_gdf=cities_gdf,
|
|
367
|
+
output=output,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Shared fields are promoted to the top level; strip them from the
|
|
371
|
+
# per-layer blocks so the structure stays flat and non-redundant.
|
|
372
|
+
shared_keys = ("command", "image", "region")
|
|
373
|
+
for key in shared_keys:
|
|
374
|
+
raster_block.pop(key, None)
|
|
375
|
+
station_block.pop(key, None)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"command": "overlay",
|
|
379
|
+
"image": output,
|
|
380
|
+
"region": {
|
|
381
|
+
"name": region.name,
|
|
382
|
+
"level": region.level,
|
|
383
|
+
"extent": [round(x, 3) for x in region.extent],
|
|
384
|
+
},
|
|
385
|
+
"datetime": datetime_str,
|
|
386
|
+
"date": date,
|
|
387
|
+
"raster": raster_block,
|
|
388
|
+
"station": station_block,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# FNR (HCHO/NO2 regime)
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
def _fnr_per_city_stats(
|
|
396
|
+
LON: np.ndarray,
|
|
397
|
+
LAT: np.ndarray,
|
|
398
|
+
fnr: np.ndarray,
|
|
399
|
+
cities_gdf: gpd.GeoDataFrame,
|
|
400
|
+
) -> list[dict[str, Any]]:
|
|
401
|
+
"""Per-city mean FNR and dominant regime."""
|
|
402
|
+
from shapely.vectorized import contains
|
|
403
|
+
|
|
404
|
+
from rsplot.fnr import FNR_NOX_LIMIT, FNR_VOC_LIMIT
|
|
405
|
+
|
|
406
|
+
rows: list[dict[str, Any]] = []
|
|
407
|
+
for _, row in cities_gdf.iterrows():
|
|
408
|
+
mask = contains(row.geometry, LON, LAT)
|
|
409
|
+
sub = fnr[mask]
|
|
410
|
+
finite = sub[np.isfinite(sub)]
|
|
411
|
+
if len(finite) == 0:
|
|
412
|
+
continue
|
|
413
|
+
mean_fnr = float(np.mean(finite))
|
|
414
|
+
if mean_fnr < FNR_VOC_LIMIT:
|
|
415
|
+
regime = "voc_limited"
|
|
416
|
+
elif mean_fnr <= FNR_NOX_LIMIT:
|
|
417
|
+
regime = "transition"
|
|
418
|
+
else:
|
|
419
|
+
regime = "nox_limited"
|
|
420
|
+
rows.append(
|
|
421
|
+
{
|
|
422
|
+
"name": row["name"],
|
|
423
|
+
"n_pixels": int(len(finite)),
|
|
424
|
+
"mean_fnr": _round(mean_fnr),
|
|
425
|
+
"median_fnr": _round(np.median(finite)),
|
|
426
|
+
"regime": regime,
|
|
427
|
+
"voc_limited_pct": round(
|
|
428
|
+
100 * float((finite < FNR_VOC_LIMIT).mean()), 1
|
|
429
|
+
),
|
|
430
|
+
"nox_limited_pct": round(
|
|
431
|
+
100 * float((finite > FNR_NOX_LIMIT).mean()), 1
|
|
432
|
+
),
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
rows.sort(key=lambda r: (r["mean_fnr"] is None, r["mean_fnr"] or 1e9))
|
|
436
|
+
return rows
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def build_fnr_result(
|
|
440
|
+
*,
|
|
441
|
+
region: RegionInfo,
|
|
442
|
+
fnr_out: FnrResult,
|
|
443
|
+
res: float,
|
|
444
|
+
output: str,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""Build the JSON sidecar for an FNR run."""
|
|
447
|
+
from rsplot.fnr import (
|
|
448
|
+
FNR_DISPLAY_MAX,
|
|
449
|
+
FNR_NOX_LIMIT,
|
|
450
|
+
FNR_VOC_LIMIT,
|
|
451
|
+
regime_percentages,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
fnr = fnr_out.fnr
|
|
455
|
+
finite = fnr[np.isfinite(fnr)]
|
|
456
|
+
|
|
457
|
+
# Per-pixel coverage histogram: how many of the requested days had
|
|
458
|
+
# both NO2 and HCHO valid?
|
|
459
|
+
from shapely.vectorized import contains
|
|
460
|
+
|
|
461
|
+
region_mask = contains(region.geometry, fnr_out.LON, fnr_out.LAT)
|
|
462
|
+
total_region_pixels = int(region_mask.sum())
|
|
463
|
+
n_valid_fnr = int(np.isfinite(fnr).sum())
|
|
464
|
+
coverage_pct = (
|
|
465
|
+
round(100 * n_valid_fnr / total_region_pixels, 3)
|
|
466
|
+
if total_region_pixels
|
|
467
|
+
else 0.0
|
|
468
|
+
)
|
|
469
|
+
both_valid_days = np.minimum(fnr_out.n_valid_hcho, fnr_out.n_valid_no2)
|
|
470
|
+
covered = both_valid_days[(both_valid_days > 0) & region_mask]
|
|
471
|
+
coverage_days = {
|
|
472
|
+
"min": int(covered.min()) if len(covered) else 0,
|
|
473
|
+
"max": int(covered.max()) if len(covered) else 0,
|
|
474
|
+
"mean": round(float(covered.mean()), 1) if len(covered) else 0.0,
|
|
475
|
+
}
|
|
476
|
+
diagnostic_masks = {
|
|
477
|
+
name: mask & region_mask
|
|
478
|
+
for name, mask in fnr_out.diagnostic_masks.items()
|
|
479
|
+
}
|
|
480
|
+
mask_diagnostics = {
|
|
481
|
+
"total_region_pixels": total_region_pixels,
|
|
482
|
+
"coverage_ge_n_min_pixels": int(
|
|
483
|
+
diagnostic_masks["coverage_ok"].sum()
|
|
484
|
+
),
|
|
485
|
+
"no2_ge_threshold_pixels": int(diagnostic_masks["no2_ok"].sum()),
|
|
486
|
+
"hcho_nonnegative_pixels": int(diagnostic_masks["hcho_ok"].sum()),
|
|
487
|
+
"final_valid_pixels": n_valid_fnr,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
result: dict[str, Any] = {
|
|
491
|
+
"command": "fnr",
|
|
492
|
+
"image": output,
|
|
493
|
+
"region": {
|
|
494
|
+
"name": region.name,
|
|
495
|
+
"level": region.level,
|
|
496
|
+
"extent": [round(x, 3) for x in region.extent],
|
|
497
|
+
},
|
|
498
|
+
"window": {
|
|
499
|
+
"start": fnr_out.dates_requested[0],
|
|
500
|
+
"end": fnr_out.dates_requested[-1],
|
|
501
|
+
"n_days_requested": len(fnr_out.dates_requested),
|
|
502
|
+
"n_days_found_no2": len(fnr_out.dates_found_no2),
|
|
503
|
+
"n_days_found_hcho": len(fnr_out.dates_found_hcho),
|
|
504
|
+
"dates_failed_no2": fnr_out.dates_failed_no2,
|
|
505
|
+
"dates_failed_hcho": fnr_out.dates_failed_hcho,
|
|
506
|
+
},
|
|
507
|
+
"params": {
|
|
508
|
+
"n_min": fnr_out.n_min,
|
|
509
|
+
"no2_min_column": fnr_out.no2_min_column,
|
|
510
|
+
"voc_threshold": FNR_VOC_LIMIT,
|
|
511
|
+
"nox_threshold": FNR_NOX_LIMIT,
|
|
512
|
+
"display_max": FNR_DISPLAY_MAX,
|
|
513
|
+
"resolution_deg": res,
|
|
514
|
+
},
|
|
515
|
+
"stats": {
|
|
516
|
+
"grid_shape": list(fnr.shape),
|
|
517
|
+
"coverage_pct": coverage_pct,
|
|
518
|
+
"valid_pixels": n_valid_fnr,
|
|
519
|
+
"total_region_pixels": total_region_pixels,
|
|
520
|
+
"coverage_days": coverage_days,
|
|
521
|
+
"mask_diagnostics": mask_diagnostics,
|
|
522
|
+
**_basic_stats(fnr),
|
|
523
|
+
},
|
|
524
|
+
"regime_pct": regime_percentages(fnr),
|
|
525
|
+
"mean_fields": {
|
|
526
|
+
"hcho_mean_raw_stats": _basic_stats(fnr_out.hcho_mean),
|
|
527
|
+
"no2_mean_raw_stats": _basic_stats(fnr_out.no2_mean),
|
|
528
|
+
"hcho_mean_filled_stats": _basic_stats(fnr_out.hcho_mean_filled),
|
|
529
|
+
"no2_mean_filled_stats": _basic_stats(fnr_out.no2_mean_filled),
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
sub_gdf = region.sub_boundary_gdf
|
|
534
|
+
if sub_gdf is not None and len(sub_gdf) > 0:
|
|
535
|
+
result["city_ranking"] = _fnr_per_city_stats(
|
|
536
|
+
fnr_out.LON, fnr_out.LAT, fnr, sub_gdf
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return result
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
# Emission
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
def emit_result(result: dict[str, Any], image_path: str) -> str:
|
|
546
|
+
"""Write {image}.json sidecar. Return sidecar path."""
|
|
547
|
+
sidecar = str(Path(image_path).with_suffix(".json"))
|
|
548
|
+
result["sidecar"] = sidecar
|
|
549
|
+
|
|
550
|
+
with open(sidecar, "w", encoding="utf-8") as f:
|
|
551
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
552
|
+
|
|
553
|
+
return sidecar
|
rsplot/tiles/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Basemap tile providers."""
|
rsplot/tiles/tianditu.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""TianDiTu (天地图) tile provider for cartopy.
|
|
2
|
+
|
|
3
|
+
Supports satellite imagery (img), vector (vec), and terrain (ter) layers.
|
|
4
|
+
Requires an API key from https://www.tianditu.gov.cn.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import cartopy.io.img_tiles as cimgt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TianDiTuTiles(cimgt.GoogleTiles):
|
|
13
|
+
"""天地图卫星影像瓦片(球面墨卡托投影)。
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
tk : TianDiTu API key.
|
|
18
|
+
layer : "img" (satellite), "vec" (vector), "ter" (terrain).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, tk: str, layer: str = "img"):
|
|
22
|
+
self.tk = tk
|
|
23
|
+
self.layer = layer
|
|
24
|
+
super().__init__()
|
|
25
|
+
|
|
26
|
+
def _image_url(self, tile):
|
|
27
|
+
x, y, z = tile
|
|
28
|
+
server = (x + y) % 8
|
|
29
|
+
return (
|
|
30
|
+
f"http://t{server}.tianditu.gov.cn/DataServer"
|
|
31
|
+
f"?T={self.layer}_w&x={x}&y={y}&l={z}&tk={self.tk}"
|
|
32
|
+
)
|