rsplot 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (30) hide show
  1. {rsplot-0.2.0 → rsplot-0.2.2}/PKG-INFO +1 -1
  2. {rsplot-0.2.0 → rsplot-0.2.2}/pyproject.toml +1 -1
  3. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/cli.py +194 -1
  4. rsplot-0.2.2/src/rsplot/recent.py +408 -0
  5. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/results.py +91 -13
  6. {rsplot-0.2.0 → rsplot-0.2.2}/.gitignore +0 -0
  7. {rsplot-0.2.0 → rsplot-0.2.2}/LICENSE +0 -0
  8. {rsplot-0.2.0 → rsplot-0.2.2}/README.md +0 -0
  9. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/__init__.py +0 -0
  10. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/__main__.py +0 -0
  11. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/config.py +0 -0
  12. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/fnr.py +0 -0
  13. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/geo/__init__.py +0 -0
  14. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/geo/boundaries.py +0 -0
  15. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/geo/gridding.py +0 -0
  16. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/__init__.py +0 -0
  17. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/colormaps.py +0 -0
  18. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/fnr.py +0 -0
  19. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/overlay.py +0 -0
  20. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/raster.py +0 -0
  21. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/station.py +0 -0
  22. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/plotting/styles.py +0 -0
  23. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/__init__.py +0 -0
  24. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/base.py +0 -0
  25. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/guokong.py +0 -0
  26. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/tropomi_hcho.py +0 -0
  27. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/tropomi_no2.py +0 -0
  28. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/readers/tropomi_o3.py +0 -0
  29. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/tiles/__init__.py +0 -0
  30. {rsplot-0.2.0 → rsplot-0.2.2}/src/rsplot/tiles/tianditu.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rsplot
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: CLI tool for plotting Remote Sensing data, designed for Agent.
5
5
  Project-URL: Repository, https://git.lug.ustc.edu.cn/yaoyhu/rsplot
6
6
  Project-URL: GitHub, https://github.com/yaoyhu/rsplot
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "CLI tool for plotting Remote Sensing data, designed for Agent."
5
5
  authors = [
6
6
  { name = "Yaoyao Hu", email = "yaoyhu@mail.ustc.edu.cn" },
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from importlib.metadata import PackageNotFoundError, version
5
6
  import math
6
7
  from typing import Any
7
8
 
@@ -46,9 +47,27 @@ def _default_raster_n_min(product: str, window_n: int) -> int:
46
47
  return max(3, math.ceil(window_n * 0.25))
47
48
  return max(3, window_n // 2)
48
49
 
50
+ def _get_version() -> str:
51
+ try:
52
+ return version("rsplot")
53
+ except PackageNotFoundError:
54
+ return "unknown"
55
+
56
+ def _version_callback(value: bool) -> None:
57
+ if value:
58
+ console.print(f"rsplot {_get_version()}")
59
+ raise typer.Exit()
49
60
 
50
61
  @app.callback()
51
- def _callback() -> None:
62
+ def _callback(
63
+ show_version: bool = typer.Option(
64
+ False,
65
+ "--version",
66
+ callback=_version_callback,
67
+ is_eager=True,
68
+ help="Show version and exit.",
69
+ ),
70
+ ) -> None:
52
71
  "CLI tool for Remote sensing raster plotting."
53
72
 
54
73
 
@@ -477,6 +496,7 @@ def station(
477
496
  console.print(f"[bold]解析区域:[/bold] {region}")
478
497
  info = resolve_region(region, cfg, level_override=level)
479
498
  console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
499
+ params = info.params
480
500
 
481
501
  # --- 4. Read station data ---
482
502
  use_data_dir = (
@@ -850,6 +870,179 @@ def overlay(
850
870
  emit_result(result, output)
851
871
 
852
872
 
873
+ @app.command()
874
+ def recent(
875
+ region: str = typer.Argument(
876
+ ..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
877
+ ),
878
+ end_date: str = typer.Argument(
879
+ ..., help="结束日期 YYYYMMDD,最近 N 天统计以该日期为窗口末端"
880
+ ),
881
+ days: int = typer.Option(
882
+ 30,
883
+ "--days",
884
+ help="最近天数,如 7 / 30 / 365",
885
+ ),
886
+ source: str = typer.Option(
887
+ "raster",
888
+ "--source",
889
+ help="数据源: raster / station",
890
+ ),
891
+ product: str = typer.Option(
892
+ "o3",
893
+ "--product",
894
+ "-p",
895
+ help="source=raster 时的产品: no2 / o3 / hcho",
896
+ ),
897
+ var: str = typer.Option(
898
+ "AQI",
899
+ "--var",
900
+ "-v",
901
+ help="source=station 时的变量名: AQI, PM2.5, PM10, NO2, O3, SO2, CO...",
902
+ ),
903
+ level: str | None = typer.Option(
904
+ None,
905
+ "--level",
906
+ "-l",
907
+ help="强制指定级别: country / key_region / province / city / county",
908
+ ),
909
+ data_dir: str | None = typer.Option(
910
+ None,
911
+ "--data-dir",
912
+ "-d",
913
+ help="数据目录 (覆盖配置默认值)",
914
+ ),
915
+ output: str | None = typer.Option(
916
+ None,
917
+ "--output",
918
+ "-o",
919
+ help="输出 JSON 路径",
920
+ ),
921
+ res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
922
+ qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
923
+ ) -> None:
924
+ """输出最近 N 天数据 JSON,用于周/月/年变化对比。
925
+
926
+ 示例:
927
+ rsplot recent 合肥 20260428 --days 30 -p hcho -o recent.json
928
+ rsplot recent 安徽 20260428 --days 7 --source station --var O3
929
+ """
930
+ from rsplot.config import STATION_VAR_META
931
+ from rsplot.geo.boundaries import resolve_region
932
+ from rsplot.readers import get_product_info
933
+ from rsplot.recent import (
934
+ build_recent_result,
935
+ parse_recent_dates,
936
+ summarize_recent_raster,
937
+ summarize_recent_station,
938
+ write_recent_result,
939
+ )
940
+
941
+ use_source = source.lower()
942
+ if use_source not in ("raster", "station"):
943
+ console.print("[red]--source 仅支持 raster / station[/red]")
944
+ raise typer.Exit(1)
945
+
946
+ try:
947
+ dates = parse_recent_dates(end_date, days)
948
+ except ValueError as e:
949
+ console.print(f"[red]{e}[/red]")
950
+ raise typer.Exit(1) from e
951
+
952
+ cfg = AppConfig.load()
953
+ console.print(f"[bold]解析区域:[/bold] {region}")
954
+ info = resolve_region(region, cfg, level_override=level)
955
+ console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
956
+ console.print(
957
+ f"[bold]统计窗口:[/bold] {dates[0]} ~ {dates[-1]} ({len(dates)} 天)"
958
+ )
959
+
960
+ if output is None:
961
+ if use_source == "raster":
962
+ output = (
963
+ f"/tmp/rsplot_recent_{info.name}_{product}_"
964
+ f"{dates[0]}_{dates[-1]}.json"
965
+ )
966
+ else:
967
+ output = (
968
+ f"/tmp/rsplot_recent_{info.name}_{var}_"
969
+ f"{dates[0]}_{dates[-1]}.json"
970
+ )
971
+
972
+ if use_source == "raster":
973
+ prod_info = get_product_info(product)
974
+ use_res = res if res is not None else info.params.res
975
+ use_qa = qa if qa is not None else cfg.qa_threshold
976
+ use_data_dir = (
977
+ data_dir if data_dir is not None else cfg.get_data_dir(product)
978
+ )
979
+ console.print(
980
+ f"\n[bold]统计栅格:[/bold] {use_data_dir} "
981
+ f"(产品: {product}, QA > {use_qa}, res={use_res})"
982
+ )
983
+ daily = summarize_recent_raster(
984
+ region=info,
985
+ product=product,
986
+ prod_info=prod_info,
987
+ data_dir=use_data_dir,
988
+ dates=dates,
989
+ res=use_res,
990
+ qa_threshold=use_qa,
991
+ console=console,
992
+ )
993
+ result = build_recent_result(
994
+ region=info,
995
+ source=use_source,
996
+ dates=dates,
997
+ daily=daily,
998
+ params={
999
+ "data_dir": use_data_dir,
1000
+ "qa_threshold": use_qa,
1001
+ "resolution_deg": use_res,
1002
+ },
1003
+ product=product,
1004
+ unit=prod_info.colorbar_label,
1005
+ output=output,
1006
+ )
1007
+ else:
1008
+ if var not in STATION_VAR_META:
1009
+ available = ", ".join(STATION_VAR_META)
1010
+ console.print(f"[red]未知变量 '{var}'[/red]。可用: {available}")
1011
+ raise typer.Exit(1)
1012
+ use_data_dir = (
1013
+ data_dir if data_dir is not None else cfg.get_data_dir("guokong")
1014
+ )
1015
+ unit = STATION_VAR_META[var][0]
1016
+ console.print(
1017
+ f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})"
1018
+ )
1019
+ daily = summarize_recent_station(
1020
+ region=info,
1021
+ data_dir=use_data_dir,
1022
+ dates=dates,
1023
+ var_name=var,
1024
+ console=console,
1025
+ )
1026
+ result = build_recent_result(
1027
+ region=info,
1028
+ source=use_source,
1029
+ dates=dates,
1030
+ daily=daily,
1031
+ params={"data_dir": use_data_dir},
1032
+ variable=var,
1033
+ unit=unit or None,
1034
+ output=output,
1035
+ )
1036
+
1037
+ write_recent_result(result, output)
1038
+ found = result["availability"]["days_found"]
1039
+ failed = result["availability"]["days_failed"]
1040
+ console.print(
1041
+ f"\n[bold green]✓ 已保存:[/bold green] {output} "
1042
+ f"(有效 {found}/{len(dates)} 天,失败 {failed})"
1043
+ )
1044
+
1045
+
853
1046
  @app.command()
854
1047
  def fnr(
855
1048
  region: str = typer.Argument(
@@ -0,0 +1,408 @@
1
+ """Recent-day JSON summaries for raster and station data.
2
+
3
+ The ``recent`` command is intentionally read/analysis only: it produces a
4
+ compact JSON payload for comparing the last week/month/year without drawing
5
+ maps. It reuses the existing product readers, gridding, and station filters so
6
+ its daily numbers match the plotting commands' data path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import numpy as np
18
+ from rich.console import Console
19
+ from rich.progress import track
20
+
21
+ from rsplot.config import STATION_VAR_META, get_exceedance_threshold
22
+ from rsplot.geo.gridding import fill_nan_gaps, grid_data, mask_to_region
23
+ from rsplot.readers import ProductInfo
24
+ from rsplot.readers.guokong import (
25
+ mask_stations_to_region,
26
+ read_stations,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from rsplot.geo.boundaries import RegionInfo
31
+
32
+
33
+ @dataclass
34
+ class RecentDaily:
35
+ """One daily summary row."""
36
+
37
+ date: str
38
+ ok: bool
39
+ value: float | None
40
+ stats: dict[str, Any]
41
+ meta: dict[str, Any]
42
+ error: str | None = None
43
+
44
+
45
+ def parse_recent_dates(end_date: str, days: int) -> list[str]:
46
+ """Return ascending YYYYMMDD dates ending on ``end_date``."""
47
+ if days < 1:
48
+ raise ValueError("--days 必须 >= 1")
49
+ if len(end_date) != 8 or not end_date.isdigit():
50
+ raise ValueError(f"日期格式错误: '{end_date}',应为 YYYYMMDD")
51
+ try:
52
+ end = datetime.strptime(end_date, "%Y%m%d")
53
+ except ValueError as e:
54
+ raise ValueError(
55
+ f"日期无效: '{end_date}',请检查年月日是否真实存在 "
56
+ f"(原始错误: {e})"
57
+ ) from e
58
+
59
+ start = end - timedelta(days=days - 1)
60
+ return [
61
+ (start + timedelta(days=i)).strftime("%Y%m%d")
62
+ for i in range(days)
63
+ ]
64
+
65
+
66
+ def summarize_recent_raster(
67
+ *,
68
+ region: "RegionInfo",
69
+ product: str,
70
+ prod_info: ProductInfo,
71
+ data_dir: str,
72
+ dates: list[str],
73
+ res: float,
74
+ qa_threshold: float,
75
+ console: Console | None = None,
76
+ ) -> list[RecentDaily]:
77
+ """Read daily raster swaths and summarize each day over the region."""
78
+ if console is None:
79
+ console = Console()
80
+
81
+ reader = prod_info.reader_cls()
82
+ buf = 1.0
83
+ read_extent = (
84
+ region.extent[0] - buf,
85
+ region.extent[1] + buf,
86
+ region.extent[2] - buf,
87
+ region.extent[3] + buf,
88
+ )
89
+ out: list[RecentDaily] = []
90
+
91
+ for d in track(dates, description=f"[cyan]统计 {product.upper()} 最近数据..."):
92
+ try:
93
+ swath = reader.read(data_dir, d, read_extent, qa_threshold)
94
+ LON, LAT, grid_raw = grid_data(
95
+ swath.lon, swath.lat, swath.values, read_extent, res
96
+ )
97
+ grid = mask_to_region(
98
+ LON, LAT, fill_nan_gaps(LON, LAT, grid_raw), region.geometry
99
+ )
100
+ stats = _grid_stats(grid)
101
+ if not stats:
102
+ raise ValueError(f"{d} 区域内无有效网格")
103
+ out.append(
104
+ RecentDaily(
105
+ date=d,
106
+ ok=True,
107
+ value=stats.get("mean"),
108
+ stats=stats,
109
+ meta={
110
+ "n_pixels": int(swath.n_pixels),
111
+ "n_files": int(swath.n_files),
112
+ "n_broken": int(swath.n_broken),
113
+ },
114
+ )
115
+ )
116
+ except (FileNotFoundError, ValueError) as e:
117
+ out.append(
118
+ RecentDaily(
119
+ date=d,
120
+ ok=False,
121
+ value=None,
122
+ stats={},
123
+ meta={},
124
+ error=str(e).splitlines()[0],
125
+ )
126
+ )
127
+
128
+ return out
129
+
130
+
131
+ def summarize_recent_station(
132
+ *,
133
+ region: "RegionInfo",
134
+ data_dir: str,
135
+ dates: list[str],
136
+ var_name: str,
137
+ console: Console | None = None,
138
+ ) -> list[RecentDaily]:
139
+ """Read daily latest-hour station files and summarize each day."""
140
+ if console is None:
141
+ console = Console()
142
+
143
+ buf = 0.5
144
+ read_extent = (
145
+ region.extent[0] - buf,
146
+ region.extent[1] + buf,
147
+ region.extent[2] - buf,
148
+ region.extent[3] + buf,
149
+ )
150
+ threshold = get_exceedance_threshold(var_name)
151
+ out: list[RecentDaily] = []
152
+
153
+ for d in track(dates, description=f"[cyan]统计 {var_name} 站点最近数据..."):
154
+ try:
155
+ data, resolved_dt = read_stations(
156
+ data_dir, d, var_name, extent=read_extent
157
+ )
158
+ data = mask_stations_to_region(data, region.geometry)
159
+ vals = data.values[np.isfinite(data.values)]
160
+ stats = _array_stats(vals)
161
+ if not stats:
162
+ raise ValueError(f"{d} 区域内无有效站点")
163
+ meta: dict[str, Any] = {
164
+ "datetime": resolved_dt,
165
+ "n_stations": int(data.n_stations),
166
+ "n_valid": int(data.n_valid),
167
+ }
168
+ if threshold is not None:
169
+ meta["threshold"] = threshold
170
+ meta["n_exceed"] = int((vals > threshold).sum())
171
+ meta["exceed_rate_pct"] = round(
172
+ 100 * meta["n_exceed"] / len(vals), 1
173
+ )
174
+ out.append(
175
+ RecentDaily(
176
+ date=d,
177
+ ok=True,
178
+ value=stats.get("mean"),
179
+ stats=stats,
180
+ meta=meta,
181
+ )
182
+ )
183
+ except (FileNotFoundError, ValueError, KeyError) as e:
184
+ out.append(
185
+ RecentDaily(
186
+ date=d,
187
+ ok=False,
188
+ value=None,
189
+ stats={},
190
+ meta={},
191
+ error=str(e).splitlines()[0],
192
+ )
193
+ )
194
+
195
+ return out
196
+
197
+
198
+ def build_recent_result(
199
+ *,
200
+ region: "RegionInfo",
201
+ source: str,
202
+ dates: list[str],
203
+ daily: list[RecentDaily],
204
+ params: dict[str, Any],
205
+ output: str,
206
+ product: str | None = None,
207
+ variable: str | None = None,
208
+ unit: str | None = None,
209
+ ) -> dict[str, Any]:
210
+ """Build the JSON payload for a recent run."""
211
+ daily_rows = [_daily_to_json(row) for row in daily]
212
+ failed = {row.date: row.error for row in daily if not row.ok and row.error}
213
+ found = [row for row in daily if row.ok]
214
+
215
+ result: dict[str, Any] = {
216
+ "command": "recent",
217
+ "image": None,
218
+ "sidecar": output,
219
+ "source": source,
220
+ "region": {
221
+ "name": region.name,
222
+ "level": region.level,
223
+ "extent": [round(x, 3) for x in region.extent],
224
+ },
225
+ "period": {
226
+ "start": dates[0],
227
+ "end": dates[-1],
228
+ "days_requested": len(dates),
229
+ },
230
+ "params": params,
231
+ "availability": {
232
+ "days_found": len(found),
233
+ "days_failed": len(failed),
234
+ "failed_dates": failed,
235
+ },
236
+ "daily": daily_rows,
237
+ "windows": _window_summaries(daily),
238
+ "changes": _change_summaries(daily),
239
+ "trend": _trend_summary(daily),
240
+ "warnings": _warnings(daily),
241
+ }
242
+ if product is not None:
243
+ result["product"] = product
244
+ if variable is not None:
245
+ result["variable"] = variable
246
+ if unit:
247
+ result["unit"] = unit
248
+ return result
249
+
250
+
251
+ def write_recent_result(result: dict[str, Any], output: str) -> str:
252
+ """Write a recent JSON result and return the path."""
253
+ path = Path(output)
254
+ path.parent.mkdir(parents=True, exist_ok=True)
255
+ with open(path, "w", encoding="utf-8") as f:
256
+ json.dump(result, f, ensure_ascii=False, indent=2)
257
+ return str(path)
258
+
259
+
260
+ def _daily_to_json(row: RecentDaily) -> dict[str, Any]:
261
+ data: dict[str, Any] = {
262
+ "date": row.date,
263
+ "ok": row.ok,
264
+ "stats": row.stats,
265
+ "meta": row.meta,
266
+ }
267
+ if row.error:
268
+ data["error"] = row.error
269
+ return data
270
+
271
+
272
+ def _window_summaries(daily: list[RecentDaily]) -> dict[str, dict[str, Any]]:
273
+ windows: dict[str, dict[str, Any]] = {}
274
+ n = len(daily)
275
+ specs = [
276
+ ("last_7", max(0, n - 7), n),
277
+ ("prev_7", max(0, n - 14), max(0, n - 7)),
278
+ ("last_30", max(0, n - 30), n),
279
+ ("prev_30", max(0, n - 60), max(0, n - 30)),
280
+ ("last_365", max(0, n - 365), n),
281
+ ]
282
+ for name, start, end in specs:
283
+ if end <= start or end - start < _required_span(name):
284
+ continue
285
+ windows[name] = _summarize_rows(daily[start:end])
286
+ return windows
287
+
288
+
289
+ def _change_summaries(daily: list[RecentDaily]) -> dict[str, dict[str, Any]]:
290
+ windows = _window_summaries(daily)
291
+ pairs = [
292
+ ("last_7_vs_prev_7", "last_7", "prev_7"),
293
+ ("last_30_vs_prev_30", "last_30", "prev_30"),
294
+ ]
295
+ out: dict[str, dict[str, Any]] = {}
296
+ for name, cur_key, prev_key in pairs:
297
+ cur = windows.get(cur_key, {})
298
+ prev = windows.get(prev_key, {})
299
+ cur_mean = cur.get("mean")
300
+ prev_mean = prev.get("mean")
301
+ if cur_mean is None or prev_mean is None:
302
+ continue
303
+ abs_change = round(float(cur_mean) - float(prev_mean), 3)
304
+ pct_change = None
305
+ if prev_mean != 0:
306
+ pct_change = round(100 * abs_change / float(prev_mean), 1)
307
+ out[name] = {
308
+ "current_mean": cur_mean,
309
+ "previous_mean": prev_mean,
310
+ "abs_change": abs_change,
311
+ "pct_change": pct_change,
312
+ "direction": _direction(abs_change),
313
+ "current_valid_days": cur.get("valid_days", 0),
314
+ "previous_valid_days": prev.get("valid_days", 0),
315
+ }
316
+ return out
317
+
318
+
319
+ def _trend_summary(daily: list[RecentDaily]) -> dict[str, Any]:
320
+ xs = []
321
+ ys = []
322
+ for i, row in enumerate(daily):
323
+ if row.ok and row.value is not None and np.isfinite(row.value):
324
+ xs.append(i)
325
+ ys.append(float(row.value))
326
+ if len(ys) < 2:
327
+ return {"valid_days": len(ys), "slope_per_day": None, "direction": "flat"}
328
+ slope = float(np.polyfit(np.asarray(xs, dtype=float), np.asarray(ys), 1)[0])
329
+ return {
330
+ "valid_days": len(ys),
331
+ "slope_per_day": round(slope, 4),
332
+ "direction": _direction(slope),
333
+ }
334
+
335
+
336
+ def _warnings(daily: list[RecentDaily]) -> list[str]:
337
+ if not daily:
338
+ return []
339
+ valid_days = sum(1 for row in daily if row.ok)
340
+ rate = valid_days / len(daily)
341
+ warnings: list[str] = []
342
+ if valid_days == 0:
343
+ warnings.append("请求窗口内无有效日期。")
344
+ elif rate < 0.5:
345
+ warnings.append("有效日期少于请求天数的一半,变化判断可信度较低。")
346
+ return warnings
347
+
348
+
349
+ def _summarize_rows(rows: list[RecentDaily]) -> dict[str, Any]:
350
+ values = np.asarray(
351
+ [row.value for row in rows if row.ok and row.value is not None],
352
+ dtype=float,
353
+ )
354
+ stats = _array_stats(values)
355
+ stats["valid_days"] = int(len(values))
356
+ stats["requested_days"] = int(len(rows))
357
+ return stats
358
+
359
+
360
+ def _grid_stats(grid: np.ndarray) -> dict[str, Any]:
361
+ total = int(grid.size)
362
+ stats = _array_stats(grid[np.isfinite(grid)])
363
+ if stats:
364
+ stats["coverage_pct"] = round(100 * stats["n"] / total, 1)
365
+ stats["grid_shape"] = list(grid.shape)
366
+ return stats
367
+
368
+
369
+ def _array_stats(values: np.ndarray) -> dict[str, Any]:
370
+ finite = np.asarray(values, dtype=float)
371
+ finite = finite[np.isfinite(finite)]
372
+ if len(finite) == 0:
373
+ return {}
374
+ return {
375
+ "n": int(len(finite)),
376
+ "min": _round(np.min(finite)),
377
+ "max": _round(np.max(finite)),
378
+ "mean": _round(np.mean(finite)),
379
+ "median": _round(np.median(finite)),
380
+ "p10": _round(np.percentile(finite, 10)),
381
+ "p25": _round(np.percentile(finite, 25)),
382
+ "p75": _round(np.percentile(finite, 75)),
383
+ "p90": _round(np.percentile(finite, 90)),
384
+ }
385
+
386
+
387
+ def _round(value: float, ndigits: int = 2) -> float | None:
388
+ if not np.isfinite(value):
389
+ return None
390
+ return round(float(value), ndigits)
391
+
392
+
393
+ def _direction(delta: float) -> str:
394
+ if delta > 0:
395
+ return "up"
396
+ if delta < 0:
397
+ return "down"
398
+ return "flat"
399
+
400
+
401
+ def _required_span(name: str) -> int:
402
+ if name.endswith("_7"):
403
+ return 7
404
+ if name.endswith("_30"):
405
+ return 30
406
+ if name.endswith("_365"):
407
+ return 365
408
+ return 1
@@ -187,21 +187,31 @@ def _attribute_stations_to_cities(
187
187
  lon: np.ndarray, lat: np.ndarray, cities_gdf: gpd.GeoDataFrame
188
188
  ) -> np.ndarray:
189
189
  """Return city name for each (lon, lat); empty string if none contain it."""
190
+ return _attribute_stations_to_areas(lon, lat, cities_gdf)
191
+
192
+
193
+ def _attribute_stations_to_areas(
194
+ lon: np.ndarray,
195
+ lat: np.ndarray,
196
+ areas_gdf: gpd.GeoDataFrame,
197
+ name_field: str = "name",
198
+ ) -> np.ndarray:
199
+ """Return containing admin-unit name for each (lon, lat)."""
190
200
  from shapely.geometry import Point
191
201
 
192
- sindex = cities_gdf.sindex
202
+ sindex = areas_gdf.sindex
193
203
  out = np.empty(len(lon), dtype=object)
194
204
  for i, (x, y) in enumerate(zip(lon, lat)):
195
205
  if not (np.isfinite(x) and np.isfinite(y)):
196
206
  out[i] = ""
197
207
  continue
198
208
  pt = Point(float(x), float(y))
199
- city = ""
209
+ area = ""
200
210
  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"]
211
+ if areas_gdf.iloc[idx].geometry.contains(pt):
212
+ area = areas_gdf.iloc[idx][name_field]
203
213
  break
204
- out[i] = city
214
+ out[i] = area
205
215
  return out
206
216
 
207
217
 
@@ -220,6 +230,46 @@ def _aqi_distribution(vals: np.ndarray) -> dict[str, int]:
220
230
  return dist
221
231
 
222
232
 
233
+ def _station_admin_level(region: RegionInfo) -> str | None:
234
+ if region.level == "country":
235
+ return "province"
236
+ if region.level == "key_region":
237
+ return "city"
238
+ if region.level == "province":
239
+ municipalities = {"北京市", "天津市", "上海市", "重庆市"}
240
+ return "county" if region.name in municipalities else "city"
241
+ if region.level == "city":
242
+ return "county"
243
+ return None
244
+
245
+
246
+ def _top_station_entries(
247
+ *,
248
+ vals: np.ndarray,
249
+ ids: np.ndarray,
250
+ lons: np.ndarray,
251
+ lats: np.ndarray,
252
+ area_names: np.ndarray | None,
253
+ admin_level: str | None,
254
+ limit: int = 10,
255
+ ) -> list[dict[str, Any]]:
256
+ top_stations: list[dict[str, Any]] = []
257
+ for i in np.argsort(vals)[::-1][:limit]:
258
+ entry: dict[str, Any] = {
259
+ "id": str(ids[i]),
260
+ "value": _round(vals[i]),
261
+ "lon": round(float(lons[i]), 3),
262
+ "lat": round(float(lats[i]), 3),
263
+ }
264
+ if area_names is not None:
265
+ entry["admin_level"] = admin_level
266
+ entry["admin_name"] = area_names[i] or None
267
+ # Backward-compatible alias for older consumers.
268
+ entry["city"] = area_names[i] or None
269
+ top_stations.append(entry)
270
+ return top_stations
271
+
272
+
223
273
  def build_station_result(
224
274
  *,
225
275
  region: RegionInfo,
@@ -235,6 +285,12 @@ def build_station_result(
235
285
  lons = data.lon[valid]
236
286
  lats = data.lat[valid]
237
287
  ids = np.asarray(data.id)[valid]
288
+ admin_gdf = (
289
+ region.sub_boundary_gdf
290
+ if region.sub_boundary_gdf is not None and len(region.sub_boundary_gdf) > 0
291
+ else cities_gdf
292
+ )
293
+ admin_level = _station_admin_level(region)
238
294
 
239
295
  result: dict[str, Any] = {
240
296
  "command": "station",
@@ -253,9 +309,25 @@ def build_station_result(
253
309
  "stats": _basic_stats(vals),
254
310
  }
255
311
 
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)
312
+ station_areas = None
313
+ if admin_gdf is not None and len(admin_gdf) > 0 and len(vals) > 0:
314
+ station_areas = _attribute_stations_to_areas(
315
+ lons, lats, admin_gdf, region.sub_name_field
316
+ )
317
+
318
+ if len(vals) > 0:
319
+ result["top_stations"] = _top_station_entries(
320
+ vals=vals,
321
+ ids=ids,
322
+ lons=lons,
323
+ lats=lats,
324
+ area_names=station_areas,
325
+ admin_level=admin_level,
326
+ limit=10,
327
+ )
328
+ result["top_station_ids"] = [
329
+ entry["id"] for entry in result["top_stations"]
330
+ ]
259
331
 
260
332
  # Exceedance summary
261
333
  if threshold is not None and len(vals) > 0:
@@ -272,8 +344,11 @@ def build_station_result(
272
344
  "lon": round(float(lons[i]), 3),
273
345
  "lat": round(float(lats[i]), 3),
274
346
  }
275
- if station_cities is not None:
276
- entry["city"] = station_cities[i] or None
347
+ if station_areas is not None:
348
+ entry["admin_level"] = admin_level
349
+ entry["admin_name"] = station_areas[i] or None
350
+ # Backward-compatible alias for older consumers.
351
+ entry["city"] = station_areas[i] or None
277
352
  top_stations.append(entry)
278
353
  if len(top_stations) >= 10:
279
354
  break
@@ -287,11 +362,11 @@ def build_station_result(
287
362
  if data.var_name == "AQI" and len(vals) > 0:
288
363
  result["aqi_distribution"] = _aqi_distribution(vals)
289
364
 
290
- # Per-city ranking
291
- if station_cities is not None and len(vals) > 0:
365
+ # Per-admin-unit ranking, exposed under the legacy city_ranking key.
366
+ if station_areas is not None and len(vals) > 0:
292
367
  acc: dict[str, dict[str, Any]] = {}
293
368
  for i in range(len(vals)):
294
- c = station_cities[i]
369
+ c = station_areas[i]
295
370
  if not c:
296
371
  continue
297
372
  s = acc.setdefault(c, {"values": [], "n_exceed": 0})
@@ -303,6 +378,9 @@ def build_station_result(
303
378
  arr = np.asarray(s["values"])
304
379
  ranking.append(
305
380
  {
381
+ "name": city,
382
+ "admin_level": admin_level,
383
+ # Backward-compatible alias for older consumers.
306
384
  "city": city,
307
385
  "n_valid": len(arr),
308
386
  "mean": _round(np.mean(arr)),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes