rsplot 0.2.1__tar.gz → 0.2.3__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.1 → rsplot-0.2.3}/PKG-INFO +4 -1
- {rsplot-0.2.1 → rsplot-0.2.3}/README.md +3 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/pyproject.toml +1 -1
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/cli.py +45 -10
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/config.py +1 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/__init__.py +35 -1
- rsplot-0.2.3/src/rsplot/readers/tropomi_o3pr.py +103 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/results.py +91 -13
- {rsplot-0.2.1 → rsplot-0.2.3}/.gitignore +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/LICENSE +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/__init__.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/__main__.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/fnr.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/boundaries.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/colormaps.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/fnr.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/recent.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.2.1 → rsplot-0.2.3}/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.3
|
|
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
|
|
@@ -279,6 +279,9 @@ rsplot raster 合肥 20260115 -p no2 -o ./logs/hcho/raster_city_jiujiang_hcho.pn
|
|
|
279
279
|
# Plot a 30-day TROPOMI window mean.
|
|
280
280
|
rsplot raster 安徽 20260131 -p hcho --days 30 -o hcho_anhui_202601.png
|
|
281
281
|
|
|
282
|
+
# Plot a TROPOMI ozone-profile tropospheric column raster.
|
|
283
|
+
rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
|
|
284
|
+
|
|
282
285
|
# Plot an explicit TROPOMI date range.
|
|
283
286
|
rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
|
|
284
287
|
|
|
@@ -52,6 +52,9 @@ rsplot raster 合肥 20260115 -p no2 -o ./logs/hcho/raster_city_jiujiang_hcho.pn
|
|
|
52
52
|
# Plot a 30-day TROPOMI window mean.
|
|
53
53
|
rsplot raster 安徽 20260131 -p hcho --days 30 -o hcho_anhui_202601.png
|
|
54
54
|
|
|
55
|
+
# Plot a TROPOMI ozone-profile tropospheric column raster.
|
|
56
|
+
rsplot raster 安徽 20260227 -p o3pr -o o3pr_anhui_20260227.png
|
|
57
|
+
|
|
55
58
|
# Plot an explicit TROPOMI date range.
|
|
56
59
|
rsplot raster 长三角 20260101-20260131 -p no2 -o no2_yrd_202601.png
|
|
57
60
|
|
|
@@ -86,7 +86,7 @@ def raster(
|
|
|
86
86
|
"o3",
|
|
87
87
|
"--product",
|
|
88
88
|
"-p",
|
|
89
|
-
help="产品类型: no2 / o3 / hcho",
|
|
89
|
+
help="产品类型: no2 / o3 / o3pr / hcho",
|
|
90
90
|
),
|
|
91
91
|
days: int | None = typer.Option(
|
|
92
92
|
None,
|
|
@@ -98,8 +98,8 @@ def raster(
|
|
|
98
98
|
None,
|
|
99
99
|
"--n-min",
|
|
100
100
|
help=(
|
|
101
|
-
"窗口模式下每像元最少有效天数。默认: NO2/O3=
|
|
102
|
-
"HCHO=max(3, ceil(天数*0.25))"
|
|
101
|
+
"窗口模式下每像元最少有效天数。默认: NO2/O3/O3PR="
|
|
102
|
+
"max(3, 天数//2), HCHO=max(3, ceil(天数*0.25))"
|
|
103
103
|
),
|
|
104
104
|
),
|
|
105
105
|
level: str | None = typer.Option(
|
|
@@ -205,12 +205,25 @@ def raster(
|
|
|
205
205
|
|
|
206
206
|
# --- 3. Merge params (level defaults + CLI overrides) ---
|
|
207
207
|
params = info.params
|
|
208
|
-
use_res = res if res is not None else params.res
|
|
209
208
|
use_dpi = dpi if dpi is not None else cfg.dpi
|
|
210
209
|
prod_info = get_product_info(product)
|
|
210
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
211
|
+
use_res = (
|
|
212
|
+
res
|
|
213
|
+
if res is not None
|
|
214
|
+
else product_defaults.res
|
|
215
|
+
if product_defaults.res is not None
|
|
216
|
+
else params.res
|
|
217
|
+
)
|
|
211
218
|
use_cmap = cmap if cmap is not None else prod_info.cmap
|
|
212
219
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
213
|
-
use_smooth =
|
|
220
|
+
use_smooth = (
|
|
221
|
+
smooth
|
|
222
|
+
if smooth is not None
|
|
223
|
+
else product_defaults.smooth_sigma
|
|
224
|
+
if product_defaults.smooth_sigma is not None
|
|
225
|
+
else params.smooth_sigma
|
|
226
|
+
)
|
|
214
227
|
|
|
215
228
|
if basemap is not None:
|
|
216
229
|
use_basemap: bool | None = basemap.lower() == "satellite"
|
|
@@ -496,6 +509,7 @@ def station(
|
|
|
496
509
|
console.print(f"[bold]解析区域:[/bold] {region}")
|
|
497
510
|
info = resolve_region(region, cfg, level_override=level)
|
|
498
511
|
console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
|
|
512
|
+
params = info.params
|
|
499
513
|
|
|
500
514
|
# --- 4. Read station data ---
|
|
501
515
|
use_data_dir = (
|
|
@@ -589,6 +603,7 @@ def station(
|
|
|
589
603
|
_PRODUCT_STATION_PAIR: dict[str, str] = {
|
|
590
604
|
"no2": "NO2",
|
|
591
605
|
"o3": "O3",
|
|
606
|
+
"o3pr": "O3",
|
|
592
607
|
}
|
|
593
608
|
|
|
594
609
|
|
|
@@ -607,7 +622,7 @@ def overlay(
|
|
|
607
622
|
"o3",
|
|
608
623
|
"--product",
|
|
609
624
|
"-p",
|
|
610
|
-
help="卫星产品类型: no2 / o3",
|
|
625
|
+
help="卫星产品类型: no2 / o3 / o3pr / hcho",
|
|
611
626
|
),
|
|
612
627
|
# --- station variable ---
|
|
613
628
|
var: str | None = typer.Option(
|
|
@@ -713,12 +728,25 @@ def overlay(
|
|
|
713
728
|
|
|
714
729
|
# --- 5. Merge params ---
|
|
715
730
|
params = info.params
|
|
716
|
-
use_res = res if res is not None else params.res
|
|
717
731
|
use_dpi = dpi if dpi is not None else cfg.dpi
|
|
718
732
|
prod_info = get_product_info(product)
|
|
733
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
734
|
+
use_res = (
|
|
735
|
+
res
|
|
736
|
+
if res is not None
|
|
737
|
+
else product_defaults.res
|
|
738
|
+
if product_defaults.res is not None
|
|
739
|
+
else params.res
|
|
740
|
+
)
|
|
719
741
|
use_cmap = cmap if cmap is not None else prod_info.cmap
|
|
720
742
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
721
|
-
use_smooth =
|
|
743
|
+
use_smooth = (
|
|
744
|
+
smooth
|
|
745
|
+
if smooth is not None
|
|
746
|
+
else product_defaults.smooth_sigma
|
|
747
|
+
if product_defaults.smooth_sigma is not None
|
|
748
|
+
else params.smooth_sigma
|
|
749
|
+
)
|
|
722
750
|
|
|
723
751
|
if basemap is not None:
|
|
724
752
|
use_basemap: bool | None = basemap.lower() == "satellite"
|
|
@@ -891,7 +919,7 @@ def recent(
|
|
|
891
919
|
"o3",
|
|
892
920
|
"--product",
|
|
893
921
|
"-p",
|
|
894
|
-
help="source=raster 时的产品: no2 / o3 / hcho",
|
|
922
|
+
help="source=raster 时的产品: no2 / o3 / o3pr / hcho",
|
|
895
923
|
),
|
|
896
924
|
var: str = typer.Option(
|
|
897
925
|
"AQI",
|
|
@@ -970,7 +998,14 @@ def recent(
|
|
|
970
998
|
|
|
971
999
|
if use_source == "raster":
|
|
972
1000
|
prod_info = get_product_info(product)
|
|
973
|
-
|
|
1001
|
+
product_defaults = prod_info.defaults_for_level(info.level)
|
|
1002
|
+
use_res = (
|
|
1003
|
+
res
|
|
1004
|
+
if res is not None
|
|
1005
|
+
else product_defaults.res
|
|
1006
|
+
if product_defaults.res is not None
|
|
1007
|
+
else info.params.res
|
|
1008
|
+
)
|
|
974
1009
|
use_qa = qa if qa is not None else cfg.qa_threshold
|
|
975
1010
|
use_data_dir = (
|
|
976
1011
|
data_dir if data_dir is not None else cfg.get_data_dir(product)
|
|
@@ -24,6 +24,7 @@ else:
|
|
|
24
24
|
DEFAULT_DATA_DIRS: dict[str, str] = {
|
|
25
25
|
"no2": "/satellite/exports2/yaoyhu/NO2",
|
|
26
26
|
"o3": "/satellite/exports2/yaoyhu/O3",
|
|
27
|
+
"o3pr": "/satellite/exports2/yaoyhu/O3PR",
|
|
27
28
|
"hcho": "/satellite/exports2/yaoyhu/HCHO",
|
|
28
29
|
"guokong": "/home/zzgsg/data/guokong_combine",
|
|
29
30
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import math
|
|
6
|
-
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
@@ -11,10 +11,19 @@ from rsplot.readers.base import BaseReader
|
|
|
11
11
|
from rsplot.readers.tropomi_hcho import TropomiHCHOReader
|
|
12
12
|
from rsplot.readers.tropomi_no2 import TropomiNO2Reader
|
|
13
13
|
from rsplot.readers.tropomi_o3 import TropomiO3Reader
|
|
14
|
+
from rsplot.readers.tropomi_o3pr import TropomiO3PRReader
|
|
14
15
|
|
|
15
16
|
HCHO_CMAP = "rsplot_hcho"
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProductPlotDefaults:
|
|
21
|
+
"""Optional per-product plotting defaults by administrative level."""
|
|
22
|
+
|
|
23
|
+
res: float | None = None
|
|
24
|
+
smooth_sigma: float | None = None
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
@dataclass
|
|
19
28
|
class ProductInfo:
|
|
20
29
|
"""Per-product metadata: reader class, display settings, auto-range config."""
|
|
@@ -25,6 +34,13 @@ class ProductInfo:
|
|
|
25
34
|
vmin: float | None # None = auto from data percentiles
|
|
26
35
|
vmax: float | None
|
|
27
36
|
min_range: float # minimum vmax-vmin to avoid exaggerating small differences
|
|
37
|
+
level_defaults: dict[str, ProductPlotDefaults] = field(
|
|
38
|
+
default_factory=dict
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def defaults_for_level(self, level: str) -> ProductPlotDefaults:
|
|
42
|
+
"""Return product-specific defaults for an administrative level."""
|
|
43
|
+
return self.level_defaults.get(level, ProductPlotDefaults())
|
|
28
44
|
|
|
29
45
|
|
|
30
46
|
PRODUCT_REGISTRY: dict[str, ProductInfo] = {
|
|
@@ -44,6 +60,24 @@ PRODUCT_REGISTRY: dict[str, ProductInfo] = {
|
|
|
44
60
|
vmax=None,
|
|
45
61
|
min_range=30.0,
|
|
46
62
|
),
|
|
63
|
+
"o3pr": ProductInfo(
|
|
64
|
+
reader_cls=TropomiO3PRReader,
|
|
65
|
+
colorbar_label="O3 Tropospheric Column from Profile (DU)",
|
|
66
|
+
cmap="YlOrRd",
|
|
67
|
+
vmin=None,
|
|
68
|
+
vmax=None,
|
|
69
|
+
min_range=5.0,
|
|
70
|
+
level_defaults={
|
|
71
|
+
# O3__PR is a coarse 30x30 km profile product. These defaults
|
|
72
|
+
# keep map pixels close to the native sampling while still using
|
|
73
|
+
# modest smoothing for display.
|
|
74
|
+
"country": ProductPlotDefaults(res=0.25, smooth_sigma=1.0),
|
|
75
|
+
"key_region": ProductPlotDefaults(res=0.15, smooth_sigma=1.2),
|
|
76
|
+
"province": ProductPlotDefaults(res=0.1, smooth_sigma=1.5),
|
|
77
|
+
"city": ProductPlotDefaults(res=0.075, smooth_sigma=1.5),
|
|
78
|
+
"county": ProductPlotDefaults(res=0.05, smooth_sigma=2.0),
|
|
79
|
+
},
|
|
80
|
+
),
|
|
47
81
|
"hcho": ProductInfo(
|
|
48
82
|
reader_cls=TropomiHCHOReader,
|
|
49
83
|
colorbar_label="HCHO VCD (x10^15 molec/cm2)",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""TROPOMI O3 profile tropospheric-column reader.
|
|
2
|
+
|
|
3
|
+
Reads ``S5P_*_L2__O3__PR`` ozone profile products and uses the provided
|
|
4
|
+
``ozone_tropospheric_column`` variable directly. Values are converted from
|
|
5
|
+
``mol m-2`` to DU.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import glob
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from rich.progress import track
|
|
15
|
+
|
|
16
|
+
from rsplot.readers.base import (
|
|
17
|
+
BaseReader,
|
|
18
|
+
SwathData,
|
|
19
|
+
read_tropomi_product_arrays,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
VALUE_NAME = "ozone_tropospheric_column"
|
|
23
|
+
|
|
24
|
+
# mol/m2 -> DU, from the product attribute
|
|
25
|
+
# multiplication_factor_to_convert_to_DU = 2241.15
|
|
26
|
+
UNIT_CONVERSION = 2241.15
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TropomiO3PRReader(BaseReader):
|
|
30
|
+
"""Reader for TROPOMI L2 O3 profile tropospheric-column files."""
|
|
31
|
+
|
|
32
|
+
def read(
|
|
33
|
+
self,
|
|
34
|
+
data_dir: str,
|
|
35
|
+
date: str,
|
|
36
|
+
extent: tuple[float, float, float, float],
|
|
37
|
+
qa_threshold: float = 0.5,
|
|
38
|
+
) -> SwathData:
|
|
39
|
+
patterns = [
|
|
40
|
+
os.path.join(data_dir, f"S5P_*_L2__O3__PR_{date}T*.nc"),
|
|
41
|
+
os.path.join(data_dir, f"S5P_*_L2__O3__PR_{date}T*.nc.zip"),
|
|
42
|
+
]
|
|
43
|
+
files: list[str] = []
|
|
44
|
+
for p in patterns:
|
|
45
|
+
files.extend(glob.glob(p))
|
|
46
|
+
files = sorted(set(files))
|
|
47
|
+
if not files:
|
|
48
|
+
raise FileNotFoundError(
|
|
49
|
+
f"未找到匹配文件:\n "
|
|
50
|
+
+ "\n ".join(patterns)
|
|
51
|
+
+ "\n请检查 --data-dir 和 --date 参数。"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
55
|
+
all_lon, all_lat, all_o3 = [], [], []
|
|
56
|
+
broken = 0
|
|
57
|
+
broken_examples: list[str] = []
|
|
58
|
+
|
|
59
|
+
for f in track(files, description="[cyan]读取 O3PR 轨道..."):
|
|
60
|
+
try:
|
|
61
|
+
lon, lat, o3, qa = read_tropomi_product_arrays(f, VALUE_NAME)
|
|
62
|
+
except (OSError, ValueError, KeyError) as e:
|
|
63
|
+
broken += 1
|
|
64
|
+
if len(broken_examples) < 3:
|
|
65
|
+
broken_examples.append(
|
|
66
|
+
f"{os.path.basename(f)}: {str(e).splitlines()[0]}"
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
mask = (
|
|
71
|
+
(qa > qa_threshold)
|
|
72
|
+
& (lon >= lon_min)
|
|
73
|
+
& (lon <= lon_max)
|
|
74
|
+
& (lat >= lat_min)
|
|
75
|
+
& (lat <= lat_max)
|
|
76
|
+
& np.isfinite(o3)
|
|
77
|
+
& (o3 < 1e30)
|
|
78
|
+
)
|
|
79
|
+
if mask.sum() > 0:
|
|
80
|
+
all_lon.append(lon[mask])
|
|
81
|
+
all_lat.append(lat[mask])
|
|
82
|
+
all_o3.append(o3[mask] * UNIT_CONVERSION)
|
|
83
|
+
|
|
84
|
+
if not all_lon:
|
|
85
|
+
broken_msg = (
|
|
86
|
+
"\n损坏/不可读示例:\n " + "\n ".join(broken_examples)
|
|
87
|
+
if broken_examples
|
|
88
|
+
else ""
|
|
89
|
+
)
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{date} 当天 O3PR 无有效数据覆盖目标区域 {extent}。\n"
|
|
92
|
+
f"共扫描 {len(files)} 个文件,损坏 {broken} 个。"
|
|
93
|
+
f"{broken_msg}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return SwathData(
|
|
97
|
+
lon=np.concatenate(all_lon),
|
|
98
|
+
lat=np.concatenate(all_lat),
|
|
99
|
+
values=np.concatenate(all_o3),
|
|
100
|
+
n_pixels=sum(len(a) for a in all_lon),
|
|
101
|
+
n_files=len(files),
|
|
102
|
+
n_broken=broken,
|
|
103
|
+
)
|
|
@@ -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 =
|
|
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
|
-
|
|
209
|
+
area = ""
|
|
200
210
|
for idx in sindex.intersection((x, y, x, y)):
|
|
201
|
-
if
|
|
202
|
-
|
|
211
|
+
if areas_gdf.iloc[idx].geometry.contains(pt):
|
|
212
|
+
area = areas_gdf.iloc[idx][name_field]
|
|
203
213
|
break
|
|
204
|
-
out[i] =
|
|
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
|
-
|
|
257
|
-
if
|
|
258
|
-
|
|
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
|
|
276
|
-
entry["
|
|
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-
|
|
291
|
-
if
|
|
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 =
|
|
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
|
|
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
|