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.
Files changed (31) hide show
  1. {rsplot-0.2.1 → rsplot-0.2.3}/PKG-INFO +4 -1
  2. {rsplot-0.2.1 → rsplot-0.2.3}/README.md +3 -0
  3. {rsplot-0.2.1 → rsplot-0.2.3}/pyproject.toml +1 -1
  4. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/cli.py +45 -10
  5. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/config.py +1 -0
  6. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/__init__.py +35 -1
  7. rsplot-0.2.3/src/rsplot/readers/tropomi_o3pr.py +103 -0
  8. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/results.py +91 -13
  9. {rsplot-0.2.1 → rsplot-0.2.3}/.gitignore +0 -0
  10. {rsplot-0.2.1 → rsplot-0.2.3}/LICENSE +0 -0
  11. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/__init__.py +0 -0
  12. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/__main__.py +0 -0
  13. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/fnr.py +0 -0
  14. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/__init__.py +0 -0
  15. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/boundaries.py +0 -0
  16. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/geo/gridding.py +0 -0
  17. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/__init__.py +0 -0
  18. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/colormaps.py +0 -0
  19. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/fnr.py +0 -0
  20. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/overlay.py +0 -0
  21. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/raster.py +0 -0
  22. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/station.py +0 -0
  23. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/plotting/styles.py +0 -0
  24. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/base.py +0 -0
  25. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/guokong.py +0 -0
  26. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_hcho.py +0 -0
  27. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_no2.py +0 -0
  28. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/readers/tropomi_o3.py +0 -0
  29. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/recent.py +0 -0
  30. {rsplot-0.2.1 → rsplot-0.2.3}/src/rsplot/tiles/__init__.py +0 -0
  31. {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.1
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.2.1"
3
+ version = "0.2.3"
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" },
@@ -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=max(3, 天数//2), "
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 = smooth if smooth is not None else params.smooth_sigma
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 = smooth if smooth is not None else params.smooth_sigma
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
- use_res = res if res is not None else info.params.res
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 = 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