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/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
@@ -0,0 +1 @@
1
+ """Basemap tile providers."""
@@ -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
+ )