rsplot 0.3.1.dev3__py3-none-any.whl → 0.3.2.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rsplot/commands/raster.py CHANGED
@@ -10,6 +10,9 @@ import typer
10
10
 
11
11
  from rsplot.commands.common import (
12
12
  parse_basemap,
13
+ resolve_satellite,
14
+ satellite_errors,
15
+ satellite_filter_label,
13
16
  validate_color_range,
14
17
  validate_non_negative,
15
18
  validate_positive,
@@ -60,11 +63,13 @@ def parse_raster_dates(
60
63
  return dates, window_mode
61
64
 
62
65
 
66
+ @satellite_errors
63
67
  def run_raster(
64
68
  *,
65
69
  region: str,
66
70
  date_arg: str,
67
71
  product: str,
72
+ sensor: str = "tropomi",
68
73
  days: int | None,
69
74
  n_min: int | None,
70
75
  level: str | None,
@@ -83,20 +88,33 @@ def run_raster(
83
88
  console: Console,
84
89
  ) -> None:
85
90
  """Execute raster orchestration without owning the Typer signature."""
86
- from rsplot.geo.boundaries import resolve_region
91
+ from rsplot.geo.boundaries import resolve_regions
87
92
  from rsplot.geo.processing import (
88
93
  buffered_extent,
94
+ prepare_display_grid,
89
95
  prepare_existing_grid,
90
96
  prepare_swath_grid,
91
97
  )
92
98
  from rsplot.plotting.raster import plot_raster
93
99
  from rsplot.plotting.styles import setup_fonts
94
- from rsplot.readers import auto_vrange, get_product_info
100
+ from rsplot.readers import auto_vrange
95
101
  from rsplot.results import build_raster_result, emit_result
96
- from rsplot.temporal import compute_window_mean
102
+ from rsplot.temporal import compute_window_mean, read_scan_mean
97
103
 
104
+ cfg = AppConfig.load()
105
+ prod_info, use_qa = resolve_satellite(product, sensor, qa, cfg, console)
106
+ sensor = prod_info.sensor
107
+ scan_mode = sensor == "gems" and len(date_arg) == 12
108
+ if scan_mode:
109
+ from rsplot.readers.gems import parse_gems_time
110
+
111
+ parse_gems_time(date_arg)
112
+ if days is not None or n_min is not None:
113
+ raise ValueError("GEMS 单次扫描不能同时使用 --days 或 --n-min。")
98
114
  try:
99
- dates, window_mode = parse_raster_dates(date_arg, days)
115
+ dates, window_mode = parse_raster_dates(
116
+ date_arg[:8] if scan_mode else date_arg, days
117
+ )
100
118
  except ValueError as e:
101
119
  console.print(f"[red]{e}[/red]")
102
120
  raise typer.Exit(1) from e
@@ -121,12 +139,18 @@ def run_raster(
121
139
  )
122
140
  raise typer.Exit(1)
123
141
 
124
- cfg = AppConfig.load()
125
142
  setup_fonts(cfg.font_dir)
126
143
 
127
144
  console.print(f"[bold]解析区域:[/bold] {region}")
128
- info = resolve_region(region, cfg, level_override=level)
145
+ info = resolve_regions(region, cfg, level_override=level)
129
146
  console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
147
+ if info.members:
148
+ console.print(
149
+ " → 成员: "
150
+ + "、".join(
151
+ member.qualified_name or member.name for member in info.members
152
+ )
153
+ )
130
154
  console.print(
131
155
  f" → 范围: {info.extent[0]:.2f}°~{info.extent[1]:.2f}°E, "
132
156
  f"{info.extent[2]:.2f}°~{info.extent[3]:.2f}°N"
@@ -134,7 +158,6 @@ def run_raster(
134
158
 
135
159
  params = info.params
136
160
  use_dpi = dpi if dpi is not None else cfg.dpi
137
- prod_info = get_product_info(product)
138
161
  product_defaults = prod_info.defaults_for_level(info.level)
139
162
  use_res = (
140
163
  res
@@ -144,7 +167,6 @@ def run_raster(
144
167
  else params.res
145
168
  )
146
169
  use_cmap = cmap if cmap is not None else prod_info.cmap
147
- use_qa = qa if qa is not None else cfg.qa_threshold
148
170
  use_smooth = (
149
171
  smooth
150
172
  if smooth is not None
@@ -175,10 +197,20 @@ def run_raster(
175
197
 
176
198
  read_extent = buffered_extent(info.extent, 1.0)
177
199
  use_data_dir = (
178
- data_dir if data_dir is not None else cfg.get_data_dir(product)
200
+ data_dir
201
+ if data_dir is not None
202
+ else cfg.get_data_dir(prod_info.data_key or product)
179
203
  )
180
204
  reader = prod_info.reader_cls()
181
205
  reader.cloud_fraction_max = cf
206
+ analysis_res = use_res
207
+ if sensor == "gems":
208
+ from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
209
+
210
+ analysis_res = GEMS_ANALYSIS_RESOLUTION
211
+ console.print(
212
+ f" → GEMS 分析网格 {analysis_res}°,显示网格 {use_res}°"
213
+ )
182
214
  swath_meta: dict[str, Any]
183
215
  window_meta: dict[str, Any] | None = None
184
216
  scientific_valid_mask: np.ndarray | None = None
@@ -187,34 +219,61 @@ def run_raster(
187
219
  console.print(
188
220
  f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
189
221
  )
190
- console.print(f" 日期: {dates[0]}, QA > {use_qa}, CF <= {cf}")
191
- swath = reader.read(
192
- use_data_dir,
193
- dates[0],
194
- read_extent,
195
- qa_threshold=use_qa,
196
- )
197
222
  console.print(
198
- f" → [green]{swath.n_pixels}[/green] 有效像元 "
199
- f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
223
+ f" 时间: {date_arg}, {satellite_filter_label(sensor, use_qa, cf)}"
200
224
  )
201
- console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
202
- prepared = prepare_swath_grid(
203
- swath.lon,
204
- swath.lat,
205
- swath.values,
206
- read_extent,
207
- use_res,
208
- info.geometry,
209
- )
210
- swath_meta = {
211
- "n_pixels": int(swath.n_pixels),
212
- "n_files": int(swath.n_files),
213
- "n_broken": int(swath.n_broken),
214
- "qa_threshold": use_qa,
215
- "cloud_fraction_max": cf,
216
- "resolution_deg": use_res,
217
- }
225
+ if sensor == "gems":
226
+ batch = read_scan_mean(
227
+ reader=reader,
228
+ data_dir=use_data_dir,
229
+ request=date_arg,
230
+ extent=read_extent,
231
+ res=analysis_res,
232
+ )
233
+ prepared = prepare_existing_grid(
234
+ batch.LON,
235
+ batch.LAT,
236
+ batch.grid,
237
+ info.geometry,
238
+ fill_gaps=False,
239
+ )
240
+ swath_meta = {
241
+ "n_pixels": batch.n_pixels,
242
+ "n_files": batch.n_files,
243
+ "n_broken": batch.n_broken,
244
+ "resolution_deg": use_res,
245
+ "cloud_fraction_max": cf,
246
+ **batch.metadata,
247
+ }
248
+ scientific_valid_mask = np.isfinite(prepared.values)
249
+ else:
250
+ swath = reader.read(
251
+ use_data_dir,
252
+ dates[0],
253
+ read_extent,
254
+ qa_threshold=use_qa,
255
+ )
256
+ console.print(
257
+ f" → [green]{swath.n_pixels}[/green] 有效像元 "
258
+ f"({swath.n_files} 文件, {swath.n_broken} 损坏)"
259
+ )
260
+ console.print(f"\n[bold]网格化:[/bold] {use_res}° 分辨率")
261
+ prepared = prepare_swath_grid(
262
+ swath.lon,
263
+ swath.lat,
264
+ swath.values,
265
+ read_extent,
266
+ use_res,
267
+ info.geometry,
268
+ )
269
+ swath_meta = {
270
+ "n_pixels": int(swath.n_pixels),
271
+ "n_files": int(swath.n_files),
272
+ "n_broken": int(swath.n_broken),
273
+ "qa_threshold": use_qa,
274
+ "cloud_fraction_max": cf,
275
+ "resolution_deg": use_res,
276
+ }
218
277
  else:
219
278
  assert use_n_min is not None
220
279
  console.print(
@@ -224,13 +283,13 @@ def run_raster(
224
283
  console.print(
225
284
  f"\n[bold]读取数据:[/bold] {use_data_dir} (产品: {product})"
226
285
  )
227
- console.print(f" QA > {use_qa}, CF <= {cf}")
286
+ console.print(f" {satellite_filter_label(sensor, use_qa, cf)}")
228
287
  window = compute_window_mean(
229
288
  reader=reader,
230
289
  data_dir=use_data_dir,
231
290
  dates=dates,
232
291
  extent=read_extent,
233
- res=use_res,
292
+ res=analysis_res,
234
293
  qa_threshold=use_qa,
235
294
  label=product.upper(),
236
295
  console=console,
@@ -287,14 +346,54 @@ def run_raster(
287
346
  "coverage_days": coverage_days,
288
347
  }
289
348
 
290
- if not np.isfinite(prepared.values).any():
349
+ if sensor == "gems":
350
+ swath_meta.pop("qa_threshold", None)
351
+ window_meta["aggregation"] = "equal_day_mean_of_equal_scan_means"
352
+ window_meta["daily"] = window.daily_metadata
353
+
354
+ display_source = prepared.unmasked_values
355
+ statistics_grid = prepared.values.copy() if sensor == "gems" else None
356
+ display_lon, display_lat = prepared.lon, prepared.lat
357
+ display_support = None
358
+ if sensor == "gems":
359
+ from rsplot.geo.footprints import prepare_footprint_display
360
+
361
+ display = prepare_footprint_display(
362
+ prepared.lon,
363
+ prepared.lat,
364
+ display_source,
365
+ info.geometry,
366
+ analysis_res=analysis_res,
367
+ display_res=use_res,
368
+ smooth_sigma=use_smooth,
369
+ )
370
+ display_lon, display_lat = display.lon, display.lat
371
+ display_grid, display_support = display.values, display.support
372
+ swath_meta["resolution_deg"] = analysis_res
373
+ swath_meta["analysis_resolution_deg"] = analysis_res
374
+ swath_meta["display_resolution_deg"] = use_res
375
+ swath_meta["display_support"] = "fixed_analysis_valid_cells"
376
+ else:
377
+ display_grid = prepare_display_grid(
378
+ prepared.lon,
379
+ prepared.lat,
380
+ display_source,
381
+ info.geometry,
382
+ smooth_sigma=use_smooth,
383
+ )
384
+
385
+ if not np.isfinite(display_grid).any():
291
386
  console.print(
292
387
  "[red]窗口均值全区无有效像元,可能原因:窗口内覆盖不足,"
293
388
  "或 --n-min 太严。可放宽 --n-min / 扩大窗口。[/red]"
294
389
  )
295
390
  raise typer.Exit(1)
296
391
 
297
- if use_smooth is not None:
392
+ if (
393
+ sensor != "gems"
394
+ and use_smooth is not None
395
+ and np.isfinite(prepared.values).any()
396
+ ):
298
397
  console.print(f" → 高斯平滑 sigma={use_smooth}")
299
398
  prepared = prepare_existing_grid(
300
399
  prepared.lon,
@@ -329,7 +428,12 @@ def run_raster(
329
428
  use_vmin = vmin if vmin is not None else prod_info.vmin
330
429
  use_vmax = vmax if vmax is not None else prod_info.vmax
331
430
  else:
332
- auto_min, auto_max = auto_vrange(prepared.values, prod_info)
431
+ auto_min, auto_max = auto_vrange(
432
+ prepared.values
433
+ if np.isfinite(prepared.values).any()
434
+ else display_grid,
435
+ prod_info,
436
+ )
333
437
  use_vmin = vmin if vmin is not None else auto_min
334
438
  use_vmax = vmax if vmax is not None else auto_max
335
439
  try:
@@ -339,11 +443,25 @@ def run_raster(
339
443
  raise typer.Exit(1) from e
340
444
  console.print(f" → 色标范围: {use_vmin:.1f} ~ {use_vmax:.1f}")
341
445
 
446
+ if output is None and sensor == "gems":
447
+ suffix = f"{dates[0]}_{dates[-1]}" if window_mode else date_arg
448
+ output = f"/tmp/rsplot_gems_{product}_{info.name}_{suffix}.png"
342
449
  if output is None:
343
450
  if window_mode:
344
451
  output = f"/tmp/rsplot_{info.name}_{dates[0]}_{dates[-1]}.png"
345
452
  else:
346
453
  output = f"/tmp/rsplot_{info.name}_{dates[0]}.png"
454
+ if title is None and sensor == "gems":
455
+ period = (
456
+ f"{dates[0]}~{dates[-1]} mean"
457
+ if window_mode
458
+ else (
459
+ f"{date_arg} CST scan"
460
+ if scan_mode
461
+ else f"{date_arg} daily mean (CST)"
462
+ )
463
+ )
464
+ title = f"GEMS {product.upper()} - {info.name} ({period})"
347
465
  if title is None and window_mode:
348
466
  title = (
349
467
  f"TROPOMI {product.upper()} - {info.name} "
@@ -352,9 +470,9 @@ def run_raster(
352
470
 
353
471
  console.print("\n[bold]绘图中...[/bold]")
354
472
  plot_raster(
355
- prepared.lon,
356
- prepared.lat,
357
- prepared.values,
473
+ display_lon,
474
+ display_lat,
475
+ display_grid,
358
476
  info,
359
477
  dpi=use_dpi,
360
478
  cmap=use_cmap,
@@ -365,6 +483,7 @@ def run_raster(
365
483
  hcho_cmap_file=cfg.hcho_cmap_file,
366
484
  title=title,
367
485
  output=output,
486
+ **({"coverage_geometry": display_support} if sensor == "gems" else {}),
368
487
  colorbar_label=prod_info.colorbar_label,
369
488
  )
370
489
  console.print(f"\n[bold green]✓ 已保存:[/bold green] {output}")
@@ -376,11 +495,16 @@ def run_raster(
376
495
  prod_info=prod_info,
377
496
  LON=prepared.lon,
378
497
  LAT=prepared.lat,
379
- grid=prepared.values,
498
+ grid=statistics_grid
499
+ if statistics_grid is not None
500
+ else prepared.values,
380
501
  swath_meta=swath_meta,
381
502
  vmin=use_vmin,
382
503
  vmax=use_vmax,
383
504
  output=output,
384
505
  window=window_meta,
506
+ display_grid=display_grid,
507
+ display_lon=display_lon,
508
+ display_lat=display_lat,
385
509
  )
386
510
  emit_result(result, output)
rsplot/commands/recent.py CHANGED
@@ -7,6 +7,9 @@ from typing import TYPE_CHECKING
7
7
  import typer
8
8
 
9
9
  from rsplot.commands.common import (
10
+ resolve_satellite,
11
+ satellite_errors,
12
+ satellite_filter_label,
10
13
  validate_positive,
11
14
  validate_unit_interval,
12
15
  )
@@ -16,6 +19,7 @@ if TYPE_CHECKING:
16
19
  from rich.console import Console
17
20
 
18
21
 
22
+ @satellite_errors
19
23
  def run_recent(
20
24
  *,
21
25
  region: str,
@@ -23,6 +27,7 @@ def run_recent(
23
27
  days: int,
24
28
  source: str,
25
29
  product: str,
30
+ sensor: str = "tropomi",
26
31
  var: str,
27
32
  level: str | None,
28
33
  data_dir: str | None,
@@ -35,7 +40,6 @@ def run_recent(
35
40
  """Execute recent-day summaries without owning the Typer signature."""
36
41
  from rsplot.config import STATION_VAR_META
37
42
  from rsplot.geo.boundaries import resolve_region
38
- from rsplot.readers import get_product_info
39
43
  from rsplot.recent import (
40
44
  build_recent_result,
41
45
  parse_recent_dates,
@@ -56,6 +60,13 @@ def run_recent(
56
60
  raise typer.Exit(1) from e
57
61
 
58
62
  cfg = AppConfig.load()
63
+ if use_source == "raster":
64
+ prod_info, use_qa = resolve_satellite(
65
+ product, sensor, qa, cfg, console
66
+ )
67
+ sensor = prod_info.sensor
68
+ elif sensor.lower() != "tropomi":
69
+ raise ValueError("--sensor gems 仅用于 --source raster。")
59
70
  console.print(f"[bold]解析区域:[/bold] {region}")
60
71
  info = resolve_region(region, cfg, level_override=level)
61
72
  console.print(f" → [green]{info.name}[/green] (级别: {info.level})")
@@ -66,7 +77,8 @@ def run_recent(
66
77
  if output is None:
67
78
  if use_source == "raster":
68
79
  output = (
69
- f"/tmp/rsplot_recent_{info.name}_{product}_"
80
+ f"/tmp/rsplot_recent_{'gems_' if sensor == 'gems' else ''}"
81
+ f"{info.name}_{product}_"
70
82
  f"{dates[0]}_{dates[-1]}.json"
71
83
  )
72
84
  else:
@@ -76,7 +88,6 @@ def run_recent(
76
88
  )
77
89
 
78
90
  if use_source == "raster":
79
- prod_info = get_product_info(product)
80
91
  product_defaults = prod_info.defaults_for_level(info.level)
81
92
  use_res = (
82
93
  res
@@ -85,7 +96,6 @@ def run_recent(
85
96
  if product_defaults.res is not None
86
97
  else info.params.res
87
98
  )
88
- use_qa = qa if qa is not None else cfg.qa_threshold
89
99
  try:
90
100
  validate_positive(use_res, "--res")
91
101
  validate_unit_interval(use_qa, "--qa")
@@ -93,13 +103,19 @@ def run_recent(
93
103
  except ValueError as e:
94
104
  console.print(f"[red]{e}[/red]")
95
105
  raise typer.Exit(1) from e
106
+ if sensor == "gems":
107
+ from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
108
+
109
+ use_res = GEMS_ANALYSIS_RESOLUTION
96
110
  use_data_dir = (
97
- data_dir if data_dir is not None else cfg.get_data_dir(product)
111
+ data_dir
112
+ if data_dir is not None
113
+ else cfg.get_data_dir(prod_info.data_key or product)
98
114
  )
99
115
  console.print(
100
116
  f"\n[bold]统计栅格:[/bold] {use_data_dir} "
101
- f"(产品: {product}, QA > {use_qa}, "
102
- f"CF <= {cf}, res={use_res})"
117
+ f"(产品: {product}, {satellite_filter_label(sensor, use_qa, cf)}, "
118
+ f"res={use_res})"
103
119
  )
104
120
  daily = summarize_recent_raster(
105
121
  region=info,
@@ -124,6 +140,8 @@ def run_recent(
124
140
  "resolution_deg": use_res,
125
141
  },
126
142
  product=product,
143
+ sensor=sensor,
144
+ quantity=prod_info.quantity,
127
145
  unit=prod_info.colorbar_label,
128
146
  output=output,
129
147
  )
@@ -132,6 +150,10 @@ def run_recent(
132
150
  available = ", ".join(STATION_VAR_META)
133
151
  console.print(f"[red]未知变量 '{var}'[/red]。可用: {available}")
134
152
  raise typer.Exit(1)
153
+ if sensor == "gems":
154
+ from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
155
+
156
+ use_res = GEMS_ANALYSIS_RESOLUTION
135
157
  use_data_dir = (
136
158
  data_dir if data_dir is not None else cfg.get_data_dir("guokong")
137
159
  )
@@ -54,7 +54,11 @@ def run_reconstruct(
54
54
  ) -> None:
55
55
  """Execute reconstructed-grid orchestration."""
56
56
  from rsplot.geo.boundaries import resolve_region
57
- from rsplot.geo.processing import buffered_extent, prepare_existing_grid
57
+ from rsplot.geo.processing import (
58
+ buffered_extent,
59
+ prepare_display_grid,
60
+ prepare_existing_grid,
61
+ )
58
62
  from rsplot.plotting.colormaps import STATION_CONCENTRATION_CMAP
59
63
  from rsplot.plotting.raster import plot_raster
60
64
  from rsplot.plotting.styles import setup_fonts
@@ -148,10 +152,22 @@ def run_reconstruct(
148
152
  info.geometry,
149
153
  fill_gaps=False,
150
154
  )
151
- if not np.isfinite(prepared.values).any():
155
+ display_source = prepared.unmasked_values
156
+ display_grid = prepare_display_grid(
157
+ prepared.lon,
158
+ prepared.lat,
159
+ display_source,
160
+ info.geometry,
161
+ smooth_sigma=smooth,
162
+ )
163
+ if not np.isfinite(display_grid).any():
152
164
  console.print("[red]目标区域内没有有效重构网格。[/red]")
153
165
  raise typer.Exit(1)
154
- if smooth is not None and smooth > 0:
166
+ if (
167
+ smooth is not None
168
+ and smooth > 0
169
+ and np.isfinite(prepared.values).any()
170
+ ):
155
171
  console.print(f" → 高斯平滑 sigma={smooth}")
156
172
  prepared = prepare_existing_grid(
157
173
  prepared.lon,
@@ -167,7 +183,12 @@ def run_reconstruct(
167
183
  f"({prepared.n_valid_region}/{prepared.total_region_pixels})"
168
184
  )
169
185
 
170
- auto_min, auto_max = auto_forecast_vrange(prepared.values, var_info)
186
+ auto_min, auto_max = auto_forecast_vrange(
187
+ prepared.values
188
+ if np.isfinite(prepared.values).any()
189
+ else display_grid,
190
+ var_info,
191
+ )
171
192
  use_vmin = vmin if vmin is not None else auto_min
172
193
  use_vmax = vmax if vmax is not None else auto_max
173
194
  try:
@@ -192,7 +213,7 @@ def run_reconstruct(
192
213
  plot_raster(
193
214
  prepared.lon,
194
215
  prepared.lat,
195
- prepared.values,
216
+ display_grid,
196
217
  info,
197
218
  dpi=use_dpi,
198
219
  cmap=use_cmap,
@@ -213,6 +234,7 @@ def run_reconstruct(
213
234
  LON=prepared.lon,
214
235
  LAT=prepared.lat,
215
236
  grid=prepared.values,
237
+ display_grid=display_grid,
216
238
  vmin=use_vmin,
217
239
  vmax=use_vmax,
218
240
  output=output,
rsplot/config.py CHANGED
@@ -23,6 +23,9 @@ else:
23
23
  # Safe built-in path defaults
24
24
  # ---------------------------------------------------------------------------
25
25
  KNOWN_DATA_PRODUCTS = (
26
+ "gems_no2",
27
+ "gems_hcho",
28
+ "gems_o3",
26
29
  "no2",
27
30
  "o3",
28
31
  "o3pr",
rsplot/geo/boundaries.py CHANGED
@@ -58,6 +58,8 @@ class RegionInfo:
58
58
  sub_name_field: str = NAME_FIELD
59
59
  administrative_level: str | None = None
60
60
  subdivision_level: str | None = None
61
+ qualified_name: str | None = None
62
+ members: tuple[RegionInfo, ...] = ()
61
63
 
62
64
 
63
65
  def _strip_suffix(name: str) -> str:
@@ -116,7 +118,9 @@ def _load_subdivision_source(
116
118
  try:
117
119
  source = gpd.read_file(path)
118
120
  except Exception as exc:
119
- raise ValueError(f"无法读取下级行政区边界文件 '{path}': {exc}") from exc
121
+ raise ValueError(
122
+ f"无法读取下级行政区边界文件 '{path}': {exc}"
123
+ ) from exc
120
124
 
121
125
  if name_field not in source.columns:
122
126
  available = ", ".join(str(column) for column in source.columns)
@@ -137,7 +141,9 @@ def _load_subdivision_source(
137
141
  normalized = source[[name_field, "geometry"]].rename(
138
142
  columns={name_field: NAME_FIELD}
139
143
  )
140
- normalized[NAME_FIELD] = normalized[NAME_FIELD].astype("string").str.strip()
144
+ normalized[NAME_FIELD] = (
145
+ normalized[NAME_FIELD].astype("string").str.strip()
146
+ )
141
147
  normalized = normalized[
142
148
  normalized[NAME_FIELD].notna()
143
149
  & normalized[NAME_FIELD].ne("")
@@ -280,6 +286,7 @@ def _resolve_qualified_county(
280
286
  main_gdf=county_match,
281
287
  sub_gdf=None,
282
288
  administrative_level=administrative_level,
289
+ qualified_name=f"{canonical_city_name}/{county_match.iloc[0][NAME_FIELD]}",
283
290
  )
284
291
 
285
292
 
@@ -451,6 +458,7 @@ def _build_info(
451
458
  detail_gdf: gpd.GeoDataFrame | None = None,
452
459
  administrative_level: str | None = None,
453
460
  subdivision_level: str | None = None,
461
+ qualified_name: str | None = None,
454
462
  ) -> RegionInfo:
455
463
  bounds = geometry.bounds # (minx, miny, maxx, maxy)
456
464
  params = LEVEL_DEFAULTS[level]
@@ -471,4 +479,99 @@ def _build_info(
471
479
  detail_boundary_gdf=detail_gdf,
472
480
  administrative_level=administrative_level,
473
481
  subdivision_level=subdivision_level,
482
+ qualified_name=qualified_name,
483
+ )
484
+
485
+
486
+ def resolve_regions(
487
+ expression: str,
488
+ cfg: AppConfig,
489
+ level_override: str | None = None,
490
+ ) -> RegionInfo:
491
+ """Resolve a comma-separated selection without changing single-region APIs."""
492
+ if "," not in expression and "," not in expression:
493
+ return resolve_region(expression, cfg, level_override)
494
+ names = [
495
+ part.replace("\r", "").replace("\n", "").strip()
496
+ for part in re.split("[,,]", expression)
497
+ ]
498
+ if any(not name for name in names):
499
+ raise ValueError("多区域列表包含空项,请检查连续逗号或首尾逗号。")
500
+ members = []
501
+ seen = set()
502
+ for index, name in enumerate(names, 1):
503
+ try:
504
+ member = resolve_region(name, cfg, level_override)
505
+ except ValueError as error:
506
+ raise ValueError(
507
+ f"第 {index} 个区域 '{name}' 无法解析:{error}"
508
+ ) from error
509
+ key = (member.level, member.qualified_name or member.name)
510
+ if key not in seen:
511
+ members.append(member)
512
+ seen.add(key)
513
+
514
+ levels = ("country", "key_region", "province", "city", "county")
515
+ level = min((member.level for member in members), key=levels.index)
516
+ geometry = unary_union([member.geometry for member in members])
517
+ crs = next(
518
+ (
519
+ m.main_boundary_gdf.crs
520
+ for m in members
521
+ if m.main_boundary_gdf is not None
522
+ and m.main_boundary_gdf.crs is not None
523
+ ),
524
+ "EPSG:4326",
474
525
  )
526
+ main = gpd.GeoDataFrame(
527
+ {NAME_FIELD: [m.qualified_name or m.name for m in members]},
528
+ geometry=[m.geometry for m in members],
529
+ crs=crs,
530
+ )
531
+ sub = _merge_member_layers(members, "sub_boundary_gdf", crs)
532
+ detail = _merge_member_layers(members, "detail_boundary_gdf", crs)
533
+ if detail is not None and sub is not None:
534
+ sub_keys = {g.normalize().wkb for g in sub.geometry}
535
+ detail = detail[
536
+ ~detail.geometry.map(lambda g: g.normalize().wkb in sub_keys)
537
+ ].copy()
538
+ info = _build_info(
539
+ name=f"联合区域({len(members)}个)",
540
+ level=level,
541
+ geometry=geometry,
542
+ main_gdf=main,
543
+ sub_gdf=sub,
544
+ detail_gdf=detail,
545
+ )
546
+ info.members = tuple(members)
547
+ return info
548
+
549
+
550
+ def _merge_member_layers(
551
+ members: list[RegionInfo],
552
+ attribute: str,
553
+ crs: object,
554
+ ) -> gpd.GeoDataFrame | None:
555
+ """Keep only each member's own subdivisions and deduplicate shared features."""
556
+ rows = []
557
+ seen = set()
558
+ for member in members:
559
+ frame = getattr(member, attribute)
560
+ if frame is None or len(frame) == 0:
561
+ continue
562
+ if frame.crs is not None and frame.crs != crs:
563
+ frame = frame.to_crs(crs)
564
+ for _, row in frame.iterrows():
565
+ geometry = row.geometry.intersection(member.geometry)
566
+ if geometry.is_empty or geometry.area == 0:
567
+ continue
568
+ key = geometry.normalize().wkb
569
+ if key in seen:
570
+ continue
571
+ seen.add(key)
572
+ rows.append(
573
+ {NAME_FIELD: row[member.sub_name_field], "geometry": geometry}
574
+ )
575
+ if not rows:
576
+ return None
577
+ return gpd.GeoDataFrame(rows, geometry="geometry", crs=crs)