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.
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/.gitignore +0 -3
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/PKG-INFO +62 -2
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/README.md +60 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/pyproject.toml +9 -8
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/config.py +45 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/boundaries.py +145 -13
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/common.py +6 -1
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/station.py +2 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/LICENSE +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/__main__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/cli.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/common.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/fnr.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/forecast.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/meteo.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/overlay.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/raster.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/recent.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/reconstruct.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/station.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/commands/station_trend.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/fnr.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/gridding.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/geo/processing.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/colormaps.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/fnr.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/map_frame.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/meteo.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/overlay.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/raster.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/station.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/station_trend.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/styles.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/plotting/wind.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/_tropomi.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/base.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/era5.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/era5_wind.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/forecast.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/guokong.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/reconstruct.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_hcho.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_no2.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_o3.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/readers/tropomi_o3pr.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/recent.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/fnr.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/forecast.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/meteo.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/overlay.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/raster.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/reconstruct.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/result_builders/station_trend.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/results.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/stats.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/temporal.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/tiles/__init__.py +0 -0
- {rsplot-0.3.0 → rsplot-0.3.1.dev3}/src/rsplot/tiles/tianditu.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: rsplot
|
|
3
|
-
Version: 0.3.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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"在 {
|
|
163
|
-
f"\n
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|