rsplot 0.1.0__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/__init__.py +3 -0
- rsplot/__main__.py +6 -0
- rsplot/cli.py +1100 -0
- rsplot/config.py +290 -0
- rsplot/fnr.py +384 -0
- rsplot/geo/__init__.py +0 -0
- rsplot/geo/boundaries.py +281 -0
- rsplot/geo/gridding.py +120 -0
- rsplot/plotting/__init__.py +1 -0
- rsplot/plotting/colormaps.py +67 -0
- rsplot/plotting/fnr.py +200 -0
- rsplot/plotting/overlay.py +241 -0
- rsplot/plotting/raster.py +222 -0
- rsplot/plotting/station.py +225 -0
- rsplot/plotting/styles.py +35 -0
- rsplot/readers/__init__.py +96 -0
- rsplot/readers/base.py +96 -0
- rsplot/readers/guokong.py +143 -0
- rsplot/readers/tropomi_hcho.py +126 -0
- rsplot/readers/tropomi_no2.py +102 -0
- rsplot/readers/tropomi_o3.py +104 -0
- rsplot/results.py +553 -0
- rsplot/tiles/__init__.py +1 -0
- rsplot/tiles/tianditu.py +32 -0
- rsplot-0.1.0.dist-info/METADATA +252 -0
- rsplot-0.1.0.dist-info/RECORD +29 -0
- rsplot-0.1.0.dist-info/WHEEL +4 -0
- rsplot-0.1.0.dist-info/entry_points.txt +2 -0
- rsplot-0.1.0.dist-info/licenses/LICENSE +201 -0
rsplot/geo/boundaries.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Administrative boundary loading and region name resolution.
|
|
2
|
+
|
|
3
|
+
Resolves user input into a RegionInfo with level, geometry,
|
|
4
|
+
sub-boundaries for overlay, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import geopandas as gpd
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from shapely.ops import unary_union
|
|
17
|
+
|
|
18
|
+
from rsplot.config import LEVEL_DEFAULTS, AppConfig, LevelParams
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from shapely.geometry.base import BaseGeometry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Suffixes to strip for fuzzy matching
|
|
25
|
+
_SUFFIXES = re.compile(
|
|
26
|
+
r"(省|市|区|县|自治区|自治州|自治县|地区|盟|林区|特别行政区)$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
NAME_FIELD = "name"
|
|
30
|
+
|
|
31
|
+
# 直辖市 — resolved as province but displayed with county-level sub-boundaries
|
|
32
|
+
MUNICIPALITIES = {"北京市", "天津市", "上海市", "重庆市"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RegionInfo:
|
|
37
|
+
"""Everything the plotting pipeline needs about a resolved region."""
|
|
38
|
+
|
|
39
|
+
name: str # display name (e.g. "芜湖市")
|
|
40
|
+
level: str # "key_region" | "province" | "city" | "county"
|
|
41
|
+
geometry: BaseGeometry # union geometry for masking
|
|
42
|
+
extent: tuple[
|
|
43
|
+
float, float, float, float
|
|
44
|
+
] # (lon_min, lon_max, lat_min, lat_max)
|
|
45
|
+
params: LevelParams # plotting parameters for this level
|
|
46
|
+
|
|
47
|
+
# sub-boundary GeoDataFrames for overlay
|
|
48
|
+
main_boundary_gdf: gpd.GeoDataFrame | None = None # red border
|
|
49
|
+
sub_boundary_gdf: gpd.GeoDataFrame | None = None # grey sub-divisions
|
|
50
|
+
detail_boundary_gdf: gpd.GeoDataFrame | None = None # finer sub-divisions (counties in province view)
|
|
51
|
+
sub_name_field: str = NAME_FIELD
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _strip_suffix(name: str) -> str:
|
|
55
|
+
return _SUFFIXES.sub("", name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fuzzy_match(
|
|
59
|
+
query: str, candidates: gpd.GeoDataFrame
|
|
60
|
+
) -> gpd.GeoDataFrame | None:
|
|
61
|
+
"""Match query against NAME_FIELD: exact first, then suffix-stripped."""
|
|
62
|
+
# exact
|
|
63
|
+
exact = candidates[candidates[NAME_FIELD] == query]
|
|
64
|
+
if len(exact) > 0:
|
|
65
|
+
return exact
|
|
66
|
+
|
|
67
|
+
# with common suffixes appended
|
|
68
|
+
for suffix in ["省", "市", "区", "县", "自治区"]:
|
|
69
|
+
padded = candidates[candidates[NAME_FIELD] == query + suffix]
|
|
70
|
+
if len(padded) > 0:
|
|
71
|
+
return padded
|
|
72
|
+
|
|
73
|
+
# strip suffix from both sides
|
|
74
|
+
q_stripped = _strip_suffix(query)
|
|
75
|
+
for _, row in candidates.iterrows():
|
|
76
|
+
if _strip_suffix(row[NAME_FIELD]) == q_stripped:
|
|
77
|
+
return candidates[candidates[NAME_FIELD] == row[NAME_FIELD]]
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# GeoJSON loaders (cached)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
@lru_cache(maxsize=1)
|
|
86
|
+
def _load_province(geojson_dir: str) -> gpd.GeoDataFrame:
|
|
87
|
+
return gpd.read_file(f"{geojson_dir}/中国_省.geojson")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@lru_cache(maxsize=1)
|
|
91
|
+
def _load_city(geojson_dir: str) -> gpd.GeoDataFrame:
|
|
92
|
+
return gpd.read_file(f"{geojson_dir}/中国_市.geojson")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@lru_cache(maxsize=1)
|
|
96
|
+
def _load_county(geojson_dir: str) -> gpd.GeoDataFrame:
|
|
97
|
+
return gpd.read_file(f"{geojson_dir}/中国_县.geojson")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Core resolver
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
def resolve_region(
|
|
104
|
+
name: str, cfg: AppConfig, level_override: str | None = None
|
|
105
|
+
) -> RegionInfo:
|
|
106
|
+
"""Resolve a user-supplied region name into a RegionInfo.
|
|
107
|
+
|
|
108
|
+
Cascade order: key_region → province → city → county.
|
|
109
|
+
If level_override is given, skip cascade and match only that level.
|
|
110
|
+
"""
|
|
111
|
+
gj = cfg.geojson_dir
|
|
112
|
+
gdf_prov = _load_province(gj)
|
|
113
|
+
gdf_city = _load_city(gj)
|
|
114
|
+
gdf_county = _load_county(gj)
|
|
115
|
+
|
|
116
|
+
# --- 0. Country level (中国 / China) ---
|
|
117
|
+
if (level_override is None or level_override == "country") and name in (
|
|
118
|
+
"中国",
|
|
119
|
+
"China",
|
|
120
|
+
"china",
|
|
121
|
+
"CHINA",
|
|
122
|
+
):
|
|
123
|
+
union_geom = unary_union(gdf_prov.geometry)
|
|
124
|
+
info = _build_info(
|
|
125
|
+
name="中国",
|
|
126
|
+
level="country",
|
|
127
|
+
geometry=union_geom,
|
|
128
|
+
main_gdf=gdf_prov,
|
|
129
|
+
sub_gdf=gdf_prov,
|
|
130
|
+
)
|
|
131
|
+
# Override extent: focus on mainland (exclude deep South China Sea)
|
|
132
|
+
info.extent = (73.0, 136.0, 17.0, 54.0)
|
|
133
|
+
return info
|
|
134
|
+
|
|
135
|
+
# --- Handle "city/county" format for county disambiguation ---
|
|
136
|
+
if "/" in name:
|
|
137
|
+
city_name, county_name = name.split("/", 1)
|
|
138
|
+
city_match = _fuzzy_match(city_name, gdf_city)
|
|
139
|
+
if city_match is None:
|
|
140
|
+
raise ValueError(f"无法识别城市 '{city_name}'。")
|
|
141
|
+
city_geom = unary_union(city_match.geometry)
|
|
142
|
+
sub_counties = gdf_county[
|
|
143
|
+
gdf_county.geometry.within(city_geom.buffer(0.01))
|
|
144
|
+
]
|
|
145
|
+
county_match = _fuzzy_match(county_name, sub_counties)
|
|
146
|
+
if county_match is None:
|
|
147
|
+
available = ", ".join(sub_counties[NAME_FIELD].tolist())
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"在 {city_match.iloc[0][NAME_FIELD]} 下未找到 '{county_name}'。"
|
|
150
|
+
f"\n可用县区: {available}"
|
|
151
|
+
)
|
|
152
|
+
union_geom = unary_union(county_match.geometry)
|
|
153
|
+
return _build_info(
|
|
154
|
+
name=county_match.iloc[0][NAME_FIELD],
|
|
155
|
+
level="county",
|
|
156
|
+
geometry=union_geom,
|
|
157
|
+
main_gdf=county_match,
|
|
158
|
+
sub_gdf=None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# --- 1. Key regions (config presets) ---
|
|
162
|
+
if level_override is None or level_override == "key_region":
|
|
163
|
+
upper = name.upper()
|
|
164
|
+
for kr_name, name_list in cfg.key_regions.items():
|
|
165
|
+
if upper == kr_name.upper() or name == kr_name:
|
|
166
|
+
# Match against both provinces and cities
|
|
167
|
+
matched_provs = gdf_prov[gdf_prov[NAME_FIELD].isin(name_list)]
|
|
168
|
+
matched_cities = gdf_city[gdf_city[NAME_FIELD].isin(name_list)]
|
|
169
|
+
if len(matched_provs) == 0 and len(matched_cities) == 0:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"重点区域 '{kr_name}' 包含的名称在 GeoJSON 中找不到: {name_list}"
|
|
172
|
+
)
|
|
173
|
+
main_gdf = pd.concat([matched_provs, matched_cities])
|
|
174
|
+
union_geom = unary_union(main_gdf.geometry)
|
|
175
|
+
# Key regions show cities only (no counties) to avoid label clutter.
|
|
176
|
+
# Pure city list (e.g. PRD): use matched cities directly.
|
|
177
|
+
# Mixed / province list (e.g. BTH, YRD): enumerate ALL cities
|
|
178
|
+
# within the union so each province also contributes its cities.
|
|
179
|
+
if len(matched_provs) == 0:
|
|
180
|
+
sub = matched_cities
|
|
181
|
+
else:
|
|
182
|
+
sub = gdf_city[gdf_city.geometry.intersects(union_geom)]
|
|
183
|
+
return _build_info(
|
|
184
|
+
name=kr_name,
|
|
185
|
+
level="key_region",
|
|
186
|
+
geometry=union_geom,
|
|
187
|
+
main_gdf=main_gdf,
|
|
188
|
+
sub_gdf=sub,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# --- 2. Province ---
|
|
192
|
+
if level_override is None or level_override == "province":
|
|
193
|
+
match = _fuzzy_match(name, gdf_prov)
|
|
194
|
+
if match is not None:
|
|
195
|
+
prov_name = match.iloc[0][NAME_FIELD]
|
|
196
|
+
union_geom = unary_union(match.geometry)
|
|
197
|
+
|
|
198
|
+
if prov_name in MUNICIPALITIES:
|
|
199
|
+
# 直辖市: show county/district boundaries like a city view
|
|
200
|
+
sub_counties = gdf_county[
|
|
201
|
+
gdf_county.geometry.within(union_geom.buffer(0.01))
|
|
202
|
+
]
|
|
203
|
+
return _build_info(
|
|
204
|
+
name=prov_name,
|
|
205
|
+
level="province",
|
|
206
|
+
geometry=union_geom,
|
|
207
|
+
main_gdf=match,
|
|
208
|
+
sub_gdf=sub_counties,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
# Regular province: cities + counties within each city
|
|
212
|
+
sub_cities = gdf_city[
|
|
213
|
+
gdf_city.geometry.intersects(union_geom)
|
|
214
|
+
]
|
|
215
|
+
detail_counties = gdf_county[
|
|
216
|
+
gdf_county.geometry.intersects(union_geom)
|
|
217
|
+
]
|
|
218
|
+
return _build_info(
|
|
219
|
+
name=prov_name,
|
|
220
|
+
level="province",
|
|
221
|
+
geometry=union_geom,
|
|
222
|
+
main_gdf=match,
|
|
223
|
+
sub_gdf=sub_cities,
|
|
224
|
+
detail_gdf=detail_counties,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# --- 3. City ---
|
|
228
|
+
if level_override is None or level_override == "city":
|
|
229
|
+
match = _fuzzy_match(name, gdf_city)
|
|
230
|
+
if match is not None:
|
|
231
|
+
union_geom = unary_union(match.geometry)
|
|
232
|
+
sub_counties = gdf_county[
|
|
233
|
+
gdf_county.geometry.within(union_geom.buffer(0.01))
|
|
234
|
+
]
|
|
235
|
+
return _build_info(
|
|
236
|
+
name=match.iloc[0][NAME_FIELD],
|
|
237
|
+
level="city",
|
|
238
|
+
geometry=union_geom,
|
|
239
|
+
main_gdf=match,
|
|
240
|
+
sub_gdf=sub_counties,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# --- 4. County ---
|
|
244
|
+
if level_override == "county":
|
|
245
|
+
# "/" case already handled above; bare name with --level county is ambiguous
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"县/区级别需要指定所属城市,格式: 城市/县区,如 合肥/蜀山、成都/高新"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"无法识别区域 '{name}'。请检查名称是否正确,"
|
|
252
|
+
"或使用 --level 指定级别 (key_region/province/city/county)。"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _build_info(
|
|
257
|
+
name: str,
|
|
258
|
+
level: str,
|
|
259
|
+
geometry: BaseGeometry,
|
|
260
|
+
main_gdf: gpd.GeoDataFrame | None,
|
|
261
|
+
sub_gdf: gpd.GeoDataFrame | None,
|
|
262
|
+
detail_gdf: gpd.GeoDataFrame | None = None,
|
|
263
|
+
) -> RegionInfo:
|
|
264
|
+
bounds = geometry.bounds # (minx, miny, maxx, maxy)
|
|
265
|
+
params = LEVEL_DEFAULTS[level]
|
|
266
|
+
extent = (
|
|
267
|
+
bounds[0],
|
|
268
|
+
bounds[2],
|
|
269
|
+
bounds[1],
|
|
270
|
+
bounds[3],
|
|
271
|
+
) # (lon_min, lon_max, lat_min, lat_max)
|
|
272
|
+
return RegionInfo(
|
|
273
|
+
name=name,
|
|
274
|
+
level=level,
|
|
275
|
+
geometry=geometry,
|
|
276
|
+
extent=extent,
|
|
277
|
+
params=params,
|
|
278
|
+
main_boundary_gdf=main_gdf,
|
|
279
|
+
sub_boundary_gdf=sub_gdf,
|
|
280
|
+
detail_boundary_gdf=detail_gdf,
|
|
281
|
+
)
|
rsplot/geo/gridding.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Gridding and smoothing for satellite swath data.
|
|
2
|
+
|
|
3
|
+
Bins irregular satellite pixels onto a regular lat/lon grid,
|
|
4
|
+
applies region masking and optional NaN-aware Gaussian smoothing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy.stats import binned_statistic_2d
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from shapely.geometry.base import BaseGeometry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def grid_data(
|
|
19
|
+
lon: np.ndarray,
|
|
20
|
+
lat: np.ndarray,
|
|
21
|
+
values: np.ndarray,
|
|
22
|
+
extent: tuple[float, float, float, float],
|
|
23
|
+
res: float,
|
|
24
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
25
|
+
"""Bin irregular data onto a regular grid.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
lon, lat, values : 1-D arrays of the same length.
|
|
30
|
+
extent : (lon_min, lon_max, lat_min, lat_max).
|
|
31
|
+
res : grid resolution in degrees.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
LON, LAT : 2-D meshgrid arrays.
|
|
36
|
+
grid : 2-D array of mean values (NaN where no data).
|
|
37
|
+
"""
|
|
38
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
39
|
+
lon_bins = np.arange(lon_min, lon_max + res, res)
|
|
40
|
+
lat_bins = np.arange(lat_min, lat_max + res, res)
|
|
41
|
+
|
|
42
|
+
grid = (
|
|
43
|
+
binned_statistic_2d(
|
|
44
|
+
lon, lat, values, statistic="mean", bins=[lon_bins, lat_bins]
|
|
45
|
+
).statistic.T
|
|
46
|
+
) # transpose: binned_statistic_2d returns (x, y), we want (lat, lon)
|
|
47
|
+
|
|
48
|
+
lon_c = 0.5 * (lon_bins[:-1] + lon_bins[1:])
|
|
49
|
+
lat_c = 0.5 * (lat_bins[:-1] + lat_bins[1:])
|
|
50
|
+
LON, LAT = np.meshgrid(lon_c, lat_c)
|
|
51
|
+
|
|
52
|
+
return LON, LAT, grid
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fill_nan_gaps(
|
|
56
|
+
LON: np.ndarray, LAT: np.ndarray, grid: np.ndarray, max_gap: int = 3
|
|
57
|
+
) -> np.ndarray:
|
|
58
|
+
"""Fill small NaN gaps using nearest-neighbor interpolation.
|
|
59
|
+
|
|
60
|
+
Only fills NaN cells within *max_gap* cells of valid data, preventing
|
|
61
|
+
long-range extrapolation across large empty swath gaps.
|
|
62
|
+
"""
|
|
63
|
+
from scipy.interpolate import NearestNDInterpolator
|
|
64
|
+
from scipy.ndimage import binary_dilation
|
|
65
|
+
|
|
66
|
+
valid = np.isfinite(grid)
|
|
67
|
+
if valid.all() or not valid.any():
|
|
68
|
+
return grid
|
|
69
|
+
|
|
70
|
+
# Only fill NaN cells near existing data
|
|
71
|
+
fill_zone = binary_dilation(valid, iterations=max_gap) & ~valid
|
|
72
|
+
if not fill_zone.any():
|
|
73
|
+
return grid
|
|
74
|
+
|
|
75
|
+
interp = NearestNDInterpolator(
|
|
76
|
+
np.column_stack((LON[valid], LAT[valid])), grid[valid]
|
|
77
|
+
)
|
|
78
|
+
filled = grid.copy()
|
|
79
|
+
filled[fill_zone] = interp(LON[fill_zone], LAT[fill_zone])
|
|
80
|
+
return filled
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mask_to_region(
|
|
84
|
+
LON: np.ndarray,
|
|
85
|
+
LAT: np.ndarray,
|
|
86
|
+
grid: np.ndarray,
|
|
87
|
+
geometry: BaseGeometry,
|
|
88
|
+
) -> np.ndarray:
|
|
89
|
+
"""Mask grid to region boundary using shapely vectorized contains."""
|
|
90
|
+
from shapely.vectorized import contains
|
|
91
|
+
|
|
92
|
+
region_mask = contains(geometry, LON, LAT)
|
|
93
|
+
return np.where(region_mask, grid, np.nan)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def smooth_grid(
|
|
97
|
+
grid: np.ndarray,
|
|
98
|
+
geometry: BaseGeometry,
|
|
99
|
+
LON: np.ndarray,
|
|
100
|
+
LAT: np.ndarray,
|
|
101
|
+
sigma: float = 2.0,
|
|
102
|
+
) -> np.ndarray:
|
|
103
|
+
"""NaN-aware Gaussian smoothing using astropy, re-masked to region boundary.
|
|
104
|
+
|
|
105
|
+
Uses astropy's convolve with nan_treatment='interpolate' to avoid
|
|
106
|
+
boundary dilution that scipy.ndimage.gaussian_filter causes.
|
|
107
|
+
"""
|
|
108
|
+
from astropy.convolution import Gaussian2DKernel, convolve
|
|
109
|
+
from shapely.vectorized import contains
|
|
110
|
+
|
|
111
|
+
kernel = Gaussian2DKernel(x_stddev=sigma)
|
|
112
|
+
smoothed = convolve(
|
|
113
|
+
grid, kernel, nan_treatment="interpolate", preserve_nan=False
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Re-mask to region boundary (smoothing bleeds outside)
|
|
117
|
+
region_mask = contains(geometry, LON, LAT)
|
|
118
|
+
smoothed[~region_mask] = np.nan
|
|
119
|
+
|
|
120
|
+
return smoothed
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plotting module: raster maps, styles, annotations, basemaps."""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Project colormap definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import matplotlib as mpl
|
|
9
|
+
import matplotlib.colors as mcolors
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
HCHO_CMAP = "rsplot_hcho"
|
|
13
|
+
STATION_CONCENTRATION_CMAP = "rsplot_station_concentration"
|
|
14
|
+
|
|
15
|
+
DEFAULT_HCHO_CMAP_FILE = (
|
|
16
|
+
"/exports/XCZ/python/tmp/pycharm_project_24/colormap_22.txt"
|
|
17
|
+
)
|
|
18
|
+
HCHO_CMAP_FILE_ENV = "RSPLOT_HCHO_CMAP_FILE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_rsplot_colormaps() -> None:
|
|
22
|
+
"""Register custom colormaps used by rsplot plots."""
|
|
23
|
+
_register(_build_hcho_cmap())
|
|
24
|
+
_register(
|
|
25
|
+
mcolors.LinearSegmentedColormap.from_list(
|
|
26
|
+
STATION_CONCENTRATION_CMAP,
|
|
27
|
+
[
|
|
28
|
+
"#fff7ec",
|
|
29
|
+
"#fee8c8",
|
|
30
|
+
"#fdbb84",
|
|
31
|
+
"#fc8d59",
|
|
32
|
+
"#e34a33",
|
|
33
|
+
"#b30000",
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_hcho_cmap() -> mcolors.Colormap:
|
|
40
|
+
cmap_file = Path(os.environ.get(HCHO_CMAP_FILE_ENV, DEFAULT_HCHO_CMAP_FILE))
|
|
41
|
+
if cmap_file.exists():
|
|
42
|
+
data = np.loadtxt(cmap_file)
|
|
43
|
+
if data.shape != (256, 3):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"{cmap_file} must contain a 256x3 RGB colormap table, "
|
|
46
|
+
f"got shape {data.shape}"
|
|
47
|
+
)
|
|
48
|
+
cmap = mcolors.ListedColormap(data, name=HCHO_CMAP)
|
|
49
|
+
else:
|
|
50
|
+
cmap = mcolors.LinearSegmentedColormap.from_list(
|
|
51
|
+
HCHO_CMAP,
|
|
52
|
+
[
|
|
53
|
+
"#ffffff",
|
|
54
|
+
"#fff7bc",
|
|
55
|
+
"#fec44f",
|
|
56
|
+
"#fd8d3c",
|
|
57
|
+
"#e31a1c",
|
|
58
|
+
"#800026",
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
cmap.set_under("w")
|
|
62
|
+
return cmap
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _register(cmap: mcolors.Colormap) -> None:
|
|
66
|
+
if cmap.name not in mpl.colormaps:
|
|
67
|
+
mpl.colormaps.register(cmap)
|
rsplot/plotting/fnr.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""FNR (HCHO/NO2) map plotting with discrete regime colormap.
|
|
2
|
+
|
|
3
|
+
Uses the same boundary / basemap / gridline conventions as plot_raster,
|
|
4
|
+
but swaps the continuous colorbar for a 3-segment discrete one aligned
|
|
5
|
+
with the FNR_VOC_LIMIT / FNR_NOX_LIMIT thresholds so the regime readout
|
|
6
|
+
is directly visible on the map.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import cartopy.crs as ccrs
|
|
14
|
+
import cartopy.feature as cfeature
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
import matplotlib.ticker as mticker
|
|
17
|
+
import numpy as np
|
|
18
|
+
from cartopy.feature import ShapelyFeature
|
|
19
|
+
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
|
|
20
|
+
from matplotlib.colors import BoundaryNorm, ListedColormap
|
|
21
|
+
|
|
22
|
+
from rsplot.fnr import FNR_DISPLAY_MAX, FNR_NOX_LIMIT, FNR_VOC_LIMIT
|
|
23
|
+
from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
|
|
24
|
+
from rsplot.tiles.tianditu import TianDiTuTiles
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Fixed regime palette — deliberately kept consistent across levels so
|
|
31
|
+
# readers recognise the same semantics at first glance.
|
|
32
|
+
_REGIME_COLORS = [
|
|
33
|
+
"#d7301f", # VOC-limited (FNR < 1) — red
|
|
34
|
+
"#fdae61", # transition (1-2) — orange/yellow
|
|
35
|
+
"#2c7fb8", # NOx-limited (FNR > 2) — blue
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_fnr_cmap() -> tuple[ListedColormap, BoundaryNorm]:
|
|
40
|
+
cmap = ListedColormap(_REGIME_COLORS, name="fnr_regime")
|
|
41
|
+
cmap.set_bad(color="#dddddd", alpha=0.0) # NaN → transparent
|
|
42
|
+
bounds = [0.0, FNR_VOC_LIMIT, FNR_NOX_LIMIT, FNR_DISPLAY_MAX]
|
|
43
|
+
norm = BoundaryNorm(bounds, ncolors=cmap.N)
|
|
44
|
+
return cmap, norm
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def plot_fnr(
|
|
48
|
+
LON: np.ndarray,
|
|
49
|
+
LAT: np.ndarray,
|
|
50
|
+
fnr: np.ndarray,
|
|
51
|
+
region: RegionInfo,
|
|
52
|
+
*,
|
|
53
|
+
dpi: int = 300,
|
|
54
|
+
basemap: bool | None = None,
|
|
55
|
+
tianditu_key: str | None = None,
|
|
56
|
+
title: str | None = None,
|
|
57
|
+
output: str | None = None,
|
|
58
|
+
subtitle: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Draw an FNR regime map."""
|
|
61
|
+
params = region.params
|
|
62
|
+
use_basemap = basemap if basemap is not None else params.basemap
|
|
63
|
+
|
|
64
|
+
if use_basemap and tianditu_key:
|
|
65
|
+
tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
|
|
66
|
+
projection = tiles.crs
|
|
67
|
+
else:
|
|
68
|
+
tiles = None
|
|
69
|
+
projection = ccrs.PlateCarree()
|
|
70
|
+
|
|
71
|
+
fig, ax = plt.subplots(
|
|
72
|
+
figsize=_figsize_for_level(region.level),
|
|
73
|
+
dpi=dpi,
|
|
74
|
+
subplot_kw={"projection": projection},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
cmap, norm = _build_fnr_cmap()
|
|
78
|
+
alpha = 0.65 if use_basemap else 0.9
|
|
79
|
+
im = ax.pcolormesh(
|
|
80
|
+
LON,
|
|
81
|
+
LAT,
|
|
82
|
+
fnr,
|
|
83
|
+
transform=ccrs.PlateCarree(),
|
|
84
|
+
cmap=cmap,
|
|
85
|
+
norm=norm,
|
|
86
|
+
shading="auto",
|
|
87
|
+
alpha=alpha,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if tiles is not None:
|
|
91
|
+
ax.add_image(tiles, params.zoom)
|
|
92
|
+
|
|
93
|
+
# --- Boundaries (same layering as plot_raster) ---
|
|
94
|
+
if (
|
|
95
|
+
region.detail_boundary_gdf is not None
|
|
96
|
+
and len(region.detail_boundary_gdf) > 0
|
|
97
|
+
):
|
|
98
|
+
ax.add_feature(
|
|
99
|
+
ShapelyFeature(
|
|
100
|
+
region.detail_boundary_gdf.geometry,
|
|
101
|
+
ccrs.PlateCarree(),
|
|
102
|
+
edgecolor="#aaa",
|
|
103
|
+
facecolor="none",
|
|
104
|
+
linewidth=0.25,
|
|
105
|
+
linestyle=":",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
if (
|
|
109
|
+
region.sub_boundary_gdf is not None
|
|
110
|
+
and len(region.sub_boundary_gdf) > 0
|
|
111
|
+
):
|
|
112
|
+
is_county_sub = region.level == "city"
|
|
113
|
+
ax.add_feature(
|
|
114
|
+
ShapelyFeature(
|
|
115
|
+
region.sub_boundary_gdf.geometry,
|
|
116
|
+
ccrs.PlateCarree(),
|
|
117
|
+
edgecolor="#888" if not is_county_sub else "#666",
|
|
118
|
+
facecolor="none",
|
|
119
|
+
linewidth=0.4 if not is_county_sub else 0.6,
|
|
120
|
+
linestyle="-" if not is_county_sub else "--",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
_add_sub_labels(ax, region)
|
|
124
|
+
if region.main_boundary_gdf is not None:
|
|
125
|
+
ax.add_feature(
|
|
126
|
+
ShapelyFeature(
|
|
127
|
+
region.main_boundary_gdf.geometry,
|
|
128
|
+
ccrs.PlateCarree(),
|
|
129
|
+
edgecolor="red",
|
|
130
|
+
facecolor="none",
|
|
131
|
+
linewidth=params.boundary_linewidth,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not use_basemap:
|
|
136
|
+
ax.coastlines(linewidth=0.5, color="#333")
|
|
137
|
+
ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
|
|
138
|
+
|
|
139
|
+
lon_min, lon_max, lat_min, lat_max = region.extent
|
|
140
|
+
pad = params.pad
|
|
141
|
+
ax.set_extent(
|
|
142
|
+
[lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
|
|
143
|
+
crs=ccrs.PlateCarree(),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
gl = ax.gridlines(
|
|
147
|
+
draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
|
|
148
|
+
)
|
|
149
|
+
gl.top_labels = gl.right_labels = False
|
|
150
|
+
gl.xformatter = LONGITUDE_FORMATTER
|
|
151
|
+
gl.yformatter = LATITUDE_FORMATTER
|
|
152
|
+
gl.xlocator = mticker.MultipleLocator(params.grid_step)
|
|
153
|
+
gl.ylocator = mticker.MultipleLocator(params.grid_step)
|
|
154
|
+
|
|
155
|
+
# --- Discrete colorbar with regime labels centered in each band ---
|
|
156
|
+
shrink = 0.6 if region.level in ("key_region", "province") else 0.7
|
|
157
|
+
cb = plt.colorbar(
|
|
158
|
+
im,
|
|
159
|
+
ax=ax,
|
|
160
|
+
shrink=shrink,
|
|
161
|
+
pad=0.05,
|
|
162
|
+
ticks=[
|
|
163
|
+
FNR_VOC_LIMIT / 2,
|
|
164
|
+
(FNR_VOC_LIMIT + FNR_NOX_LIMIT) / 2,
|
|
165
|
+
(FNR_NOX_LIMIT + FNR_DISPLAY_MAX) / 2,
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
cb.ax.set_yticklabels(
|
|
169
|
+
[
|
|
170
|
+
f"VOC-limited\n(<{FNR_VOC_LIMIT:.0f})",
|
|
171
|
+
f"Transition\n({FNR_VOC_LIMIT:.0f}-{FNR_NOX_LIMIT:.0f})",
|
|
172
|
+
f"NOx-limited\n(>{FNR_NOX_LIMIT:.0f})",
|
|
173
|
+
],
|
|
174
|
+
fontsize=8,
|
|
175
|
+
)
|
|
176
|
+
cb.set_label("FNR = HCHO / NO2", fontsize=9)
|
|
177
|
+
|
|
178
|
+
# --- Title + subtitle ---
|
|
179
|
+
if title is None:
|
|
180
|
+
title = f"FNR regime — {region.name}"
|
|
181
|
+
ax.set_title(title, fontsize=14)
|
|
182
|
+
if subtitle:
|
|
183
|
+
ax.text(
|
|
184
|
+
0.5,
|
|
185
|
+
-0.08,
|
|
186
|
+
subtitle,
|
|
187
|
+
transform=ax.transAxes,
|
|
188
|
+
ha="center",
|
|
189
|
+
va="top",
|
|
190
|
+
fontsize=9,
|
|
191
|
+
color="#444",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
plt.tight_layout()
|
|
195
|
+
|
|
196
|
+
if output:
|
|
197
|
+
fig.savefig(output, dpi=dpi, bbox_inches="tight")
|
|
198
|
+
plt.close(fig)
|
|
199
|
+
else:
|
|
200
|
+
plt.show()
|