rsplot 0.3.0__tar.gz → 0.3.1.dev3__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 (64) hide show
  1. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/.gitignore +0 -3
  2. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/PKG-INFO +62 -2
  3. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/README.md +60 -0
  4. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/pyproject.toml +9 -8
  5. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/config.py +45 -0
  6. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/boundaries.py +145 -13
  7. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/common.py +6 -1
  8. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/station.py +2 -0
  9. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/LICENSE +0 -0
  10. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/__init__.py +0 -0
  11. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/__main__.py +0 -0
  12. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/cli.py +0 -0
  13. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/__init__.py +0 -0
  14. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/common.py +0 -0
  15. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/fnr.py +0 -0
  16. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/forecast.py +0 -0
  17. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/meteo.py +0 -0
  18. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/overlay.py +0 -0
  19. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/raster.py +0 -0
  20. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/recent.py +0 -0
  21. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/reconstruct.py +0 -0
  22. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/station.py +0 -0
  23. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/station_trend.py +0 -0
  24. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/fnr.py +0 -0
  25. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/__init__.py +0 -0
  26. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/gridding.py +0 -0
  27. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/processing.py +0 -0
  28. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/__init__.py +0 -0
  29. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/colormaps.py +0 -0
  30. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/fnr.py +0 -0
  31. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/map_frame.py +0 -0
  32. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/meteo.py +0 -0
  33. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/overlay.py +0 -0
  34. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/raster.py +0 -0
  35. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/station.py +0 -0
  36. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/station_trend.py +0 -0
  37. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/styles.py +0 -0
  38. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/wind.py +0 -0
  39. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/__init__.py +0 -0
  40. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/_tropomi.py +0 -0
  41. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/base.py +0 -0
  42. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/era5.py +0 -0
  43. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/era5_wind.py +0 -0
  44. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/forecast.py +0 -0
  45. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/guokong.py +0 -0
  46. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/reconstruct.py +0 -0
  47. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_hcho.py +0 -0
  48. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_no2.py +0 -0
  49. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_o3.py +0 -0
  50. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_o3pr.py +0 -0
  51. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/recent.py +0 -0
  52. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/__init__.py +0 -0
  53. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/fnr.py +0 -0
  54. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/forecast.py +0 -0
  55. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/meteo.py +0 -0
  56. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/overlay.py +0 -0
  57. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/raster.py +0 -0
  58. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/reconstruct.py +0 -0
  59. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/station_trend.py +0 -0
  60. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/results.py +0 -0
  61. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/stats.py +0 -0
  62. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/temporal.py +0 -0
  63. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/tiles/__init__.py +0 -0
  64. {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/tiles/tianditu.py +0 -0
@@ -280,7 +280,4 @@ tags
280
280
  logs/
281
281
  .vscode/
282
282
  .claude
283
- docs/
284
283
  tests/
285
- skills/
286
- tools/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: rsplot
3
- Version: 0.3.0
3
+ Version: 0.3.1.dev3
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
@@ -247,6 +247,19 @@ uv add rsplot
247
247
  uv run rsplot --help
248
248
  ```
249
249
 
250
+ ### Development releases
251
+
252
+ Every push to `main` publishes a PEP 440 development release such as
253
+ `0.3.1.dev4`. Stable installs continue to ignore these releases. To follow the
254
+ latest development build, opt in once:
255
+
256
+ ```bash
257
+ uv tool install rsplot --prerelease allow
258
+ uv tool upgrade rsplot
259
+ ```
260
+
261
+ The prerelease setting is retained by later `uv tool upgrade` commands.
262
+
250
263
  For local development:
251
264
 
252
265
  ```bash
@@ -284,6 +297,12 @@ era5 = "/path/to/era5"
284
297
  forecast = "/path/to/forecast"
285
298
  reconstruct = "/path/to/reconstruct"
286
299
 
300
+ # Optional lower-level boundary override for a direct-administered city.
301
+ # [subdivision_overrides."中山市"]
302
+ # path = "/path/to/中山市镇街界线.shp"
303
+ # name_field = "镇街名称"
304
+ # level = "township"
305
+
287
306
  # Optional satellite basemap key. Leave commented unless you have a real key.
288
307
  # [tianditu]
289
308
  # api_key = "your-real-key"
@@ -316,6 +335,11 @@ Command-line directory options, such as `--data-dir`, take precedence for the
316
335
  command being run. If no TianDiTu API key is configured, use `--basemap none`
317
336
  to avoid requesting satellite tiles.
318
337
 
338
+ City subdivision overrides replace the county layer only for the configured
339
+ city. Their source CRS is converted to the CRS of the standard city boundary.
340
+ This lets direct-administered towns or streets appear in maps and spatial
341
+ summaries, and makes qualified inputs such as `中山/板芙` resolvable.
342
+
319
343
  ## Usage
320
344
 
321
345
  The examples below assume data paths are configured and readable.
@@ -415,3 +439,39 @@ output resolution, regional statistics, administrative summaries, and hotspots.
415
439
  ## License
416
440
 
417
441
  This project is distributed under the license in `LICENSE`.
442
+
443
+ ## Publishing
444
+
445
+ The package version is derived from Git by `hatch-vcs`; do not add a static
446
+ `project.version` or make version-bump commits. The latest `vX.Y.Z` tag is the
447
+ stable version. Commits after it build as the next patch development series:
448
+
449
+ ```text
450
+ v0.3.0 -> 0.3.0
451
+ first commit -> 0.3.1.dev1
452
+ second commit -> 0.3.1.dev2
453
+ v0.3.1 -> 0.3.1
454
+ ```
455
+
456
+ GitHub Actions publishes every `main` push through the `pypi-dev` environment
457
+ and every `v*` tag through `pypi`. Configure two PyPI Trusted Publishers with
458
+ the same owner, repository, and workflow, differing only by environment:
459
+
460
+ ```text
461
+ Owner: yaoyhu
462
+ Repository: rsplot
463
+ Workflow: publish.yml
464
+ Environment: pypi-dev
465
+ Environment: pypi
466
+ ```
467
+
468
+ Leave `pypi-dev` automatic for continuous development uploads. Protect the
469
+ `pypi` GitHub environment with required reviewers so stable releases need an
470
+ explicit approval.
471
+
472
+ To make a stable release after the desired commit is on GitHub:
473
+
474
+ ```bash
475
+ git tag -a v0.3.1 -m v0.3.1
476
+ git push github v0.3.1
477
+ ```
@@ -20,6 +20,19 @@ uv add rsplot
20
20
  uv run rsplot --help
21
21
  ```
22
22
 
23
+ ### Development releases
24
+
25
+ Every push to `main` publishes a PEP 440 development release such as
26
+ `0.3.1.dev4`. Stable installs continue to ignore these releases. To follow the
27
+ latest development build, opt in once:
28
+
29
+ ```bash
30
+ uv tool install rsplot --prerelease allow
31
+ uv tool upgrade rsplot
32
+ ```
33
+
34
+ The prerelease setting is retained by later `uv tool upgrade` commands.
35
+
23
36
  For local development:
24
37
 
25
38
  ```bash
@@ -57,6 +70,12 @@ era5 = "/path/to/era5"
57
70
  forecast = "/path/to/forecast"
58
71
  reconstruct = "/path/to/reconstruct"
59
72
 
73
+ # Optional lower-level boundary override for a direct-administered city.
74
+ # [subdivision_overrides."中山市"]
75
+ # path = "/path/to/中山市镇街界线.shp"
76
+ # name_field = "镇街名称"
77
+ # level = "township"
78
+
60
79
  # Optional satellite basemap key. Leave commented unless you have a real key.
61
80
  # [tianditu]
62
81
  # api_key = "your-real-key"
@@ -89,6 +108,11 @@ Command-line directory options, such as `--data-dir`, take precedence for the
89
108
  command being run. If no TianDiTu API key is configured, use `--basemap none`
90
109
  to avoid requesting satellite tiles.
91
110
 
111
+ City subdivision overrides replace the county layer only for the configured
112
+ city. Their source CRS is converted to the CRS of the standard city boundary.
113
+ This lets direct-administered towns or streets appear in maps and spatial
114
+ summaries, and makes qualified inputs such as `中山/板芙` resolvable.
115
+
92
116
  ## Usage
93
117
 
94
118
  The examples below assume data paths are configured and readable.
@@ -188,3 +212,39 @@ output resolution, regional statistics, administrative summaries, and hotspots.
188
212
  ## License
189
213
 
190
214
  This project is distributed under the license in `LICENSE`.
215
+
216
+ ## Publishing
217
+
218
+ The package version is derived from Git by `hatch-vcs`; do not add a static
219
+ `project.version` or make version-bump commits. The latest `vX.Y.Z` tag is the
220
+ stable version. Commits after it build as the next patch development series:
221
+
222
+ ```text
223
+ v0.3.0 -> 0.3.0
224
+ first commit -> 0.3.1.dev1
225
+ second commit -> 0.3.1.dev2
226
+ v0.3.1 -> 0.3.1
227
+ ```
228
+
229
+ GitHub Actions publishes every `main` push through the `pypi-dev` environment
230
+ and every `v*` tag through `pypi`. Configure two PyPI Trusted Publishers with
231
+ the same owner, repository, and workflow, differing only by environment:
232
+
233
+ ```text
234
+ Owner: yaoyhu
235
+ Repository: rsplot
236
+ Workflow: publish.yml
237
+ Environment: pypi-dev
238
+ Environment: pypi
239
+ ```
240
+
241
+ Leave `pypi-dev` automatic for continuous development uploads. Protect the
242
+ `pypi` GitHub environment with required reviewers so stable releases need an
243
+ explicit approval.
244
+
245
+ To make a stable release after the desired commit is on GitHub:
246
+
247
+ ```bash
248
+ git tag -a v0.3.1 -m v0.3.1
249
+ git push github v0.3.1
250
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rsplot"
3
- version = "0.3.0"
3
+ dynamic = ["version"]
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" },
@@ -37,9 +37,16 @@ GitHub = "https://github.com/yaoyhu/rsplot"
37
37
  rsplot = "rsplot.__main__:app"
38
38
 
39
39
  [build-system]
40
- requires = ["hatchling"]
40
+ requires = ["hatchling", "hatch-vcs>=0.5,<0.6"]
41
41
  build-backend = "hatchling.build"
42
42
 
43
+ [tool.hatch.version]
44
+ source = "vcs"
45
+
46
+ [tool.hatch.version.raw-options]
47
+ version_scheme = "guess-next-dev"
48
+ local_scheme = "no-local-version"
49
+
43
50
  [tool.hatch.build.targets.sdist]
44
51
  only-include = ["src/rsplot"]
45
52
 
@@ -58,9 +65,3 @@ ignore = ["E501", "RUF001", "RUF002"]
58
65
 
59
66
  [tool.pytest.ini_options]
60
67
  testpaths = ["tests"]
61
-
62
- [dependency-groups]
63
- dev = [
64
- "pytest>=9.1.1",
65
- "ruff>=0.15.21",
66
- ]
@@ -62,6 +62,15 @@ class LevelParams:
62
62
  zoom: int # TianDiTu tile zoom level
63
63
 
64
64
 
65
+ @dataclass(frozen=True)
66
+ class SubdivisionSource:
67
+ """External boundary source replacing a city's county-level layer."""
68
+
69
+ path: str
70
+ name_field: str = "name"
71
+ level: str = "township"
72
+
73
+
65
74
  LEVEL_DEFAULTS: dict[str, LevelParams] = {
66
75
  "country": LevelParams(
67
76
  res=0.1,
@@ -166,6 +175,9 @@ class AppConfig:
166
175
  hcho_cmap_file: str | None = DEFAULT_HCHO_CMAP_FILE
167
176
  tianditu_key: str | None = DEFAULT_TIANDITU_KEY
168
177
  config_files: list[str] = field(default_factory=list)
178
+ subdivision_overrides: dict[str, SubdivisionSource] = field(
179
+ default_factory=dict
180
+ )
169
181
 
170
182
  # plotting defaults
171
183
  dpi: int = 300
@@ -269,6 +281,39 @@ class AppConfig:
269
281
  for name, provinces in kr.items():
270
282
  self.key_regions[name] = provinces
271
283
 
284
+ overrides = data.get("subdivision_overrides", {})
285
+ if not isinstance(overrides, dict):
286
+ raise ValueError("[subdivision_overrides] 必须是 TOML 表。")
287
+ for parent, raw_source in overrides.items():
288
+ if isinstance(raw_source, str):
289
+ source = SubdivisionSource(path=_normalize_path(raw_source))
290
+ elif isinstance(raw_source, dict):
291
+ path = raw_source.get("path")
292
+ if not path:
293
+ raise ValueError(
294
+ f"subdivision_overrides.{parent} 缺少 path。"
295
+ )
296
+ name_field = str(raw_source.get("name_field", "name")).strip()
297
+ level = str(raw_source.get("level", "township")).strip()
298
+ if not name_field:
299
+ raise ValueError(
300
+ f"subdivision_overrides.{parent}.name_field 不能为空。"
301
+ )
302
+ if not level:
303
+ raise ValueError(
304
+ f"subdivision_overrides.{parent}.level 不能为空。"
305
+ )
306
+ source = SubdivisionSource(
307
+ path=_normalize_path(path),
308
+ name_field=name_field,
309
+ level=level,
310
+ )
311
+ else:
312
+ raise ValueError(
313
+ f"subdivision_overrides.{parent} 必须是路径或 TOML 表。"
314
+ )
315
+ self.subdivision_overrides[str(parent)] = source
316
+
272
317
 
273
318
  def _config_paths() -> list[Path]:
274
319
  """Return config files to load in priority order."""
@@ -15,7 +15,12 @@ import geopandas as gpd
15
15
  import pandas as pd
16
16
  from shapely.ops import unary_union
17
17
 
18
- from rsplot.config import LEVEL_DEFAULTS, AppConfig, LevelParams
18
+ from rsplot.config import (
19
+ LEVEL_DEFAULTS,
20
+ AppConfig,
21
+ LevelParams,
22
+ SubdivisionSource,
23
+ )
19
24
 
20
25
  if TYPE_CHECKING:
21
26
  from shapely.geometry.base import BaseGeometry
@@ -23,7 +28,7 @@ if TYPE_CHECKING:
23
28
 
24
29
  # Suffixes to strip for fuzzy matching
25
30
  _SUFFIXES = re.compile(
26
- r"(省|市|区|县|自治区|自治州|自治县|地区|盟|林区|特别行政区)$"
31
+ r"(特别行政区|自治区|自治州|自治县|街道|地区|林区|省|市|区|县|盟|镇|乡)$"
27
32
  )
28
33
 
29
34
  NAME_FIELD = "name"
@@ -51,6 +56,8 @@ class RegionInfo:
51
56
  None # finer sub-divisions (counties in province view)
52
57
  )
53
58
  sub_name_field: str = NAME_FIELD
59
+ administrative_level: str | None = None
60
+ subdivision_level: str | None = None
54
61
 
55
62
 
56
63
  def _strip_suffix(name: str) -> str:
@@ -67,7 +74,7 @@ def _fuzzy_match(
67
74
  return exact
68
75
 
69
76
  # with common suffixes appended
70
- for suffix in ["省", "市", "区", "县", "自治区"]:
77
+ for suffix in ["省", "市", "区", "县", "自治区", "镇", "乡", "街道"]:
71
78
  padded = candidates[candidates[NAME_FIELD] == query + suffix]
72
79
  if len(padded) > 0:
73
80
  return padded
@@ -99,6 +106,55 @@ def _load_county(geojson_dir: str) -> gpd.GeoDataFrame:
99
106
  return gpd.read_file(f"{geojson_dir}/中国_县.geojson")
100
107
 
101
108
 
109
+ @lru_cache(maxsize=16)
110
+ def _load_subdivision_source(
111
+ path: str,
112
+ name_field: str,
113
+ target_crs: str,
114
+ ) -> gpd.GeoDataFrame:
115
+ """Load and normalize a configured city subdivision boundary file."""
116
+ try:
117
+ source = gpd.read_file(path)
118
+ except Exception as exc:
119
+ raise ValueError(f"无法读取下级行政区边界文件 '{path}': {exc}") from exc
120
+
121
+ if name_field not in source.columns:
122
+ available = ", ".join(str(column) for column in source.columns)
123
+ raise ValueError(
124
+ f"下级行政区边界文件 '{path}' 缺少名称字段 "
125
+ f"'{name_field}'。可用字段: {available}"
126
+ )
127
+ if source.crs is None:
128
+ raise ValueError(f"下级行政区边界文件 '{path}' 缺少坐标系信息。")
129
+
130
+ try:
131
+ source = source.to_crs(target_crs)
132
+ except Exception as exc:
133
+ raise ValueError(
134
+ f"下级行政区边界文件 '{path}' 无法转换到 {target_crs}: {exc}"
135
+ ) from exc
136
+
137
+ normalized = source[[name_field, "geometry"]].rename(
138
+ columns={name_field: NAME_FIELD}
139
+ )
140
+ normalized[NAME_FIELD] = normalized[NAME_FIELD].astype("string").str.strip()
141
+ normalized = normalized[
142
+ normalized[NAME_FIELD].notna()
143
+ & normalized[NAME_FIELD].ne("")
144
+ & normalized.geometry.notna()
145
+ & ~normalized.geometry.is_empty
146
+ ].copy()
147
+ if len(normalized) == 0:
148
+ raise ValueError(f"下级行政区边界文件 '{path}' 没有可用要素。")
149
+ invalid = ~normalized.geometry.is_valid
150
+ if invalid.any():
151
+ raise ValueError(
152
+ f"下级行政区边界文件 '{path}' 包含 "
153
+ f"{int(invalid.sum())} 个无效几何。"
154
+ )
155
+ return normalized
156
+
157
+
102
158
  def load_province_boundaries(geojson_dir: str) -> gpd.GeoDataFrame:
103
159
  """Return the cached province boundary dataset."""
104
160
  return _load_province(geojson_dir)
@@ -114,6 +170,49 @@ def load_county_boundaries(geojson_dir: str) -> gpd.GeoDataFrame:
114
170
  return _load_county(geojson_dir)
115
171
 
116
172
 
173
+ def _find_subdivision_source(
174
+ city_name: str,
175
+ cfg: AppConfig,
176
+ ) -> SubdivisionSource | None:
177
+ """Match a configured override by canonical or suffix-free city name."""
178
+ if city_name in cfg.subdivision_overrides:
179
+ return cfg.subdivision_overrides[city_name]
180
+ stripped = _strip_suffix(city_name)
181
+ for configured_name, source in cfg.subdivision_overrides.items():
182
+ if _strip_suffix(configured_name) == stripped:
183
+ return source
184
+ return None
185
+
186
+
187
+ def _city_subdivisions(
188
+ city_name: str,
189
+ city_geometry: BaseGeometry,
190
+ city_crs: object,
191
+ cfg: AppConfig,
192
+ ) -> tuple[gpd.GeoDataFrame, str] | None:
193
+ """Return a configured city-specific subdivision layer when present."""
194
+ source = _find_subdivision_source(city_name, cfg)
195
+ if source is None:
196
+ return None
197
+ if city_crs is None:
198
+ raise ValueError("城市边界数据缺少坐标系,无法加载下级行政区覆盖。")
199
+
200
+ subdivisions = _load_subdivision_source(
201
+ source.path,
202
+ source.name_field,
203
+ str(city_crs),
204
+ )
205
+ subdivisions = subdivisions[
206
+ subdivisions.geometry.intersects(city_geometry.buffer(0.01))
207
+ ].copy()
208
+ if len(subdivisions) == 0:
209
+ raise ValueError(
210
+ f"为 {city_name} 配置的边界文件与城市边界没有空间交集: "
211
+ f"{source.path}"
212
+ )
213
+ return subdivisions, source.level
214
+
215
+
117
216
  # ---------------------------------------------------------------------------
118
217
  # Core resolver
119
218
  # ---------------------------------------------------------------------------
@@ -142,6 +241,7 @@ def _resolve_country(
142
241
 
143
242
  def _resolve_qualified_county(
144
243
  name: str,
244
+ cfg: AppConfig,
145
245
  cities: gpd.GeoDataFrame,
146
246
  counties: gpd.GeoDataFrame,
147
247
  ) -> RegionInfo | None:
@@ -151,16 +251,27 @@ def _resolve_qualified_county(
151
251
  city_match = _fuzzy_match(city_name, cities)
152
252
  if city_match is None:
153
253
  raise ValueError(f"无法识别城市 '{city_name}'。")
254
+ canonical_city_name = city_match.iloc[0][NAME_FIELD]
154
255
  city_geometry = unary_union(city_match.geometry)
155
- sub_counties = counties[
156
- counties.geometry.within(city_geometry.buffer(0.01))
157
- ]
256
+ configured = _city_subdivisions(
257
+ canonical_city_name,
258
+ city_geometry,
259
+ cities.crs,
260
+ cfg,
261
+ )
262
+ if configured is None:
263
+ sub_counties = counties[
264
+ counties.geometry.within(city_geometry.buffer(0.01))
265
+ ]
266
+ administrative_level = "county"
267
+ else:
268
+ sub_counties, administrative_level = configured
158
269
  county_match = _fuzzy_match(county_name, sub_counties)
159
270
  if county_match is None:
160
271
  available = ", ".join(sub_counties[NAME_FIELD].tolist())
161
272
  raise ValueError(
162
- f"在 {city_match.iloc[0][NAME_FIELD]} 下未找到 '{county_name}'。"
163
- f"\n可用县区: {available}"
273
+ f"在 {canonical_city_name} 下未找到 '{county_name}'。"
274
+ f"\n可用下级行政区: {available}"
164
275
  )
165
276
  return _build_info(
166
277
  name=county_match.iloc[0][NAME_FIELD],
@@ -168,6 +279,7 @@ def _resolve_qualified_county(
168
279
  geometry=unary_union(county_match.geometry),
169
280
  main_gdf=county_match,
170
281
  sub_gdf=None,
282
+ administrative_level=administrative_level,
171
283
  )
172
284
 
173
285
 
@@ -241,20 +353,30 @@ def _resolve_province(
241
353
 
242
354
  def _resolve_city(
243
355
  name: str,
356
+ cfg: AppConfig,
244
357
  cities: gpd.GeoDataFrame,
245
358
  counties: gpd.GeoDataFrame,
246
359
  ) -> RegionInfo | None:
247
360
  match = _fuzzy_match(name, cities)
248
361
  if match is None:
249
362
  return None
363
+ city_name = match.iloc[0][NAME_FIELD]
250
364
  geometry = unary_union(match.geometry)
251
- sub_counties = counties[counties.geometry.within(geometry.buffer(0.01))]
365
+ configured = _city_subdivisions(city_name, geometry, cities.crs, cfg)
366
+ if configured is None:
367
+ sub_counties = counties[
368
+ counties.geometry.within(geometry.buffer(0.01))
369
+ ]
370
+ subdivision_level = None
371
+ else:
372
+ sub_counties, subdivision_level = configured
252
373
  return _build_info(
253
- name=match.iloc[0][NAME_FIELD],
374
+ name=city_name,
254
375
  level="city",
255
376
  geometry=geometry,
256
377
  main_gdf=match,
257
378
  sub_gdf=sub_counties,
379
+ subdivision_level=subdivision_level,
258
380
  )
259
381
 
260
382
 
@@ -277,7 +399,12 @@ def resolve_region(
277
399
  if "/" in name:
278
400
  cities = load_city_boundaries(geojson_dir)
279
401
  counties = load_county_boundaries(geojson_dir)
280
- qualified_county = _resolve_qualified_county(name, cities, counties)
402
+ qualified_county = _resolve_qualified_county(
403
+ name,
404
+ cfg,
405
+ cities,
406
+ counties,
407
+ )
281
408
  if qualified_county is not None:
282
409
  return qualified_county
283
410
 
@@ -299,13 +426,14 @@ def resolve_region(
299
426
  if level_override is None or level_override == "city":
300
427
  cities = load_city_boundaries(geojson_dir)
301
428
  counties = load_county_boundaries(geojson_dir)
302
- city = _resolve_city(name, cities, counties)
429
+ city = _resolve_city(name, cfg, cities, counties)
303
430
  if city is not None:
304
431
  return city
305
432
 
306
433
  if level_override == "county":
307
434
  raise ValueError(
308
- "县/区级别需要指定所属城市,格式: 城市/县区,如 合肥/蜀山、成都/高新"
435
+ "县区/镇街级别需要指定所属城市,格式: 城市/下级行政区,"
436
+ "如 合肥/蜀山、中山/板芙"
309
437
  )
310
438
 
311
439
  raise ValueError(
@@ -321,6 +449,8 @@ def _build_info(
321
449
  main_gdf: gpd.GeoDataFrame | None,
322
450
  sub_gdf: gpd.GeoDataFrame | None,
323
451
  detail_gdf: gpd.GeoDataFrame | None = None,
452
+ administrative_level: str | None = None,
453
+ subdivision_level: str | None = None,
324
454
  ) -> RegionInfo:
325
455
  bounds = geometry.bounds # (minx, miny, maxx, maxy)
326
456
  params = LEVEL_DEFAULTS[level]
@@ -339,4 +469,6 @@ def _build_info(
339
469
  main_boundary_gdf=main_gdf,
340
470
  sub_boundary_gdf=sub_gdf,
341
471
  detail_boundary_gdf=detail_gdf,
472
+ administrative_level=administrative_level,
473
+ subdivision_level=subdivision_level,
342
474
  )
@@ -16,11 +16,16 @@ if TYPE_CHECKING:
16
16
 
17
17
  def serialize_region(region: RegionInfo) -> dict[str, Any]:
18
18
  """Serialize a region identically across every result type."""
19
- return {
19
+ result = {
20
20
  "name": region.name,
21
21
  "level": region.level,
22
22
  "extent": [round(value, 3) for value in region.extent],
23
23
  }
24
+ if region.administrative_level not in (None, region.level):
25
+ result["administrative_level"] = region.administrative_level
26
+ if region.subdivision_level is not None:
27
+ result["subdivision_level"] = region.subdivision_level
28
+ return result
24
29
 
25
30
 
26
31
  def grid_per_admin_stats(
@@ -57,6 +57,8 @@ def aqi_distribution(values: np.ndarray) -> dict[str, int]:
57
57
 
58
58
 
59
59
  def station_admin_level(region: RegionInfo) -> str | None:
60
+ if region.subdivision_level is not None:
61
+ return region.subdivision_level
60
62
  if region.level == "country":
61
63
  return "province"
62
64
  if region.level == "key_region":
File without changes
File without changes
File without changes
File without changes
File without changes