rsplot 0.2.0__tar.gz → 0.2.1__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.
- {rsplot-0.2.0 → rsplot-0.2.1}/PKG-INFO +1 -1
- {rsplot-0.2.0 → rsplot-0.2.1}/pyproject.toml +1 -1
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/cli.py +193 -1
- rsplot-0.2.1/src/rsplot/recent.py +408 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/.gitignore +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/LICENSE +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/README.md +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/config.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/fnr.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/geo/boundaries.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/colormaps.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/fnr.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/__init__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/results.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.0 → rsplot-0.2.1}/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.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -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(
|
|
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
|
|
|
@@ -850,6 +869,179 @@ def overlay(
|
|
|
850
869
|
emit_result(result, output)
|
|
851
870
|
|
|
852
871
|
|
|
872
|
+
@app.command()
|
|
873
|
+
def recent(
|
|
874
|
+
region: str = typer.Argument(
|
|
875
|
+
..., help="区域名称,如 中国、安徽、合肥、YRD、合肥/蜀山"
|
|
876
|
+
),
|
|
877
|
+
end_date: str = typer.Argument(
|
|
878
|
+
..., help="结束日期 YYYYMMDD,最近 N 天统计以该日期为窗口末端"
|
|
879
|
+
),
|
|
880
|
+
days: int = typer.Option(
|
|
881
|
+
30,
|
|
882
|
+
"--days",
|
|
883
|
+
help="最近天数,如 7 / 30 / 365",
|
|
884
|
+
),
|
|
885
|
+
source: str = typer.Option(
|
|
886
|
+
"raster",
|
|
887
|
+
"--source",
|
|
888
|
+
help="数据源: raster / station",
|
|
889
|
+
),
|
|
890
|
+
product: str = typer.Option(
|
|
891
|
+
"o3",
|
|
892
|
+
"--product",
|
|
893
|
+
"-p",
|
|
894
|
+
help="source=raster 时的产品: no2 / o3 / hcho",
|
|
895
|
+
),
|
|
896
|
+
var: str = typer.Option(
|
|
897
|
+
"AQI",
|
|
898
|
+
"--var",
|
|
899
|
+
"-v",
|
|
900
|
+
help="source=station 时的变量名: AQI, PM2.5, PM10, NO2, O3, SO2, CO...",
|
|
901
|
+
),
|
|
902
|
+
level: str | None = typer.Option(
|
|
903
|
+
None,
|
|
904
|
+
"--level",
|
|
905
|
+
"-l",
|
|
906
|
+
help="强制指定级别: country / key_region / province / city / county",
|
|
907
|
+
),
|
|
908
|
+
data_dir: str | None = typer.Option(
|
|
909
|
+
None,
|
|
910
|
+
"--data-dir",
|
|
911
|
+
"-d",
|
|
912
|
+
help="数据目录 (覆盖配置默认值)",
|
|
913
|
+
),
|
|
914
|
+
output: str | None = typer.Option(
|
|
915
|
+
None,
|
|
916
|
+
"--output",
|
|
917
|
+
"-o",
|
|
918
|
+
help="输出 JSON 路径",
|
|
919
|
+
),
|
|
920
|
+
res: float | None = typer.Option(None, "--res", help="网格分辨率 (度)"),
|
|
921
|
+
qa: float | None = typer.Option(0.5, "--qa", help="QA 阈值 (0-1)"),
|
|
922
|
+
) -> None:
|
|
923
|
+
"""输出最近 N 天数据 JSON,用于周/月/年变化对比。
|
|
924
|
+
|
|
925
|
+
示例:
|
|
926
|
+
rsplot recent 合肥 20260428 --days 30 -p hcho -o recent.json
|
|
927
|
+
rsplot recent 安徽 20260428 --days 7 --source station --var O3
|
|
928
|
+
"""
|
|
929
|
+
from rsplot.config import STATION_VAR_META
|
|
930
|
+
from rsplot.geo.boundaries import resolve_region
|
|
931
|
+
from rsplot.readers import get_product_info
|
|
932
|
+
from rsplot.recent import (
|
|
933
|
+
build_recent_result,
|
|
934
|
+
parse_recent_dates,
|
|
935
|
+
summarize_recent_raster,
|
|
936
|
+
summarize_recent_station,
|
|
937
|
+
write_recent_result,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
use_source = source.lower()
|
|
941
|
+
if use_source not in ("raster", "station"):
|
|
942
|
+
console.print("[red]--source 仅支持 raster / station[/red]")
|
|
943
|
+
raise typer.Exit(1)
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
dates = parse_recent_dates(end_date, days)
|
|
947
|
+
except ValueError as e:
|
|
948
|
+
console.print(f"[red]{e}[/red]")
|
|
949
|
+
raise typer.Exit(1) from e
|
|
950
|
+
|
|
951
|
+
cfg = AppConfig.load()
|
|
952
|
+
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
953
|
+
info = resolve_region(region, cfg, level_override=level)
|
|
954
|
+
console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
|
|
955
|
+
console.print(
|
|
956
|
+
f"[bold]统计窗口:[/bold] {dates[0]} ~ {dates[-1]} ({len(dates)} 天)"
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
if output is None:
|
|
960
|
+
if use_source == "raster":
|
|
961
|
+
output = (
|
|
962
|
+
f"/tmp/rsplot_recent_{info.name}_{product}_"
|
|
963
|
+
f"{dates[0]}_{dates[-1]}.json"
|
|
964
|
+
)
|
|
965
|
+
else:
|
|
966
|
+
output = (
|
|
967
|
+
f"/tmp/rsplot_recent_{info.name}_{var}_"
|
|
968
|
+
f"{dates[0]}_{dates[-1]}.json"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
if use_source == "raster":
|
|
972
|
+
prod_info = get_product_info(product)
|
|
973
|
+
use_res = res if res is not None else info.params.res
|
|
974
|
+
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
975
|
+
use_data_dir = (
|
|
976
|
+
data_dir if data_dir is not None else cfg.get_data_dir(product)
|
|
977
|
+
)
|
|
978
|
+
console.print(
|
|
979
|
+
f"\n[bold]统计栅格:[/bold] {use_data_dir} "
|
|
980
|
+
f"(产品: {product}, QA > {use_qa}, res={use_res})"
|
|
981
|
+
)
|
|
982
|
+
daily = summarize_recent_raster(
|
|
983
|
+
region=info,
|
|
984
|
+
product=product,
|
|
985
|
+
prod_info=prod_info,
|
|
986
|
+
data_dir=use_data_dir,
|
|
987
|
+
dates=dates,
|
|
988
|
+
res=use_res,
|
|
989
|
+
qa_threshold=use_qa,
|
|
990
|
+
console=console,
|
|
991
|
+
)
|
|
992
|
+
result = build_recent_result(
|
|
993
|
+
region=info,
|
|
994
|
+
source=use_source,
|
|
995
|
+
dates=dates,
|
|
996
|
+
daily=daily,
|
|
997
|
+
params={
|
|
998
|
+
"data_dir": use_data_dir,
|
|
999
|
+
"qa_threshold": use_qa,
|
|
1000
|
+
"resolution_deg": use_res,
|
|
1001
|
+
},
|
|
1002
|
+
product=product,
|
|
1003
|
+
unit=prod_info.colorbar_label,
|
|
1004
|
+
output=output,
|
|
1005
|
+
)
|
|
1006
|
+
else:
|
|
1007
|
+
if var not in STATION_VAR_META:
|
|
1008
|
+
available = ", ".join(STATION_VAR_META)
|
|
1009
|
+
console.print(f"[red]未知变量 '{var}'[/red]。可用: {available}")
|
|
1010
|
+
raise typer.Exit(1)
|
|
1011
|
+
use_data_dir = (
|
|
1012
|
+
data_dir if data_dir is not None else cfg.get_data_dir("guokong")
|
|
1013
|
+
)
|
|
1014
|
+
unit = STATION_VAR_META[var][0]
|
|
1015
|
+
console.print(
|
|
1016
|
+
f"\n[bold]统计站点:[/bold] {use_data_dir} (变量: {var})"
|
|
1017
|
+
)
|
|
1018
|
+
daily = summarize_recent_station(
|
|
1019
|
+
region=info,
|
|
1020
|
+
data_dir=use_data_dir,
|
|
1021
|
+
dates=dates,
|
|
1022
|
+
var_name=var,
|
|
1023
|
+
console=console,
|
|
1024
|
+
)
|
|
1025
|
+
result = build_recent_result(
|
|
1026
|
+
region=info,
|
|
1027
|
+
source=use_source,
|
|
1028
|
+
dates=dates,
|
|
1029
|
+
daily=daily,
|
|
1030
|
+
params={"data_dir": use_data_dir},
|
|
1031
|
+
variable=var,
|
|
1032
|
+
unit=unit or None,
|
|
1033
|
+
output=output,
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
write_recent_result(result, output)
|
|
1037
|
+
found = result["availability"]["days_found"]
|
|
1038
|
+
failed = result["availability"]["days_failed"]
|
|
1039
|
+
console.print(
|
|
1040
|
+
f"\n[bold green]✓ 已保存:[/bold green] {output} "
|
|
1041
|
+
f"(有效 {found}/{len(dates)} 天,失败 {failed})"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
853
1045
|
@app.command()
|
|
854
1046
|
def fnr(
|
|
855
1047
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|