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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Combined raster + station overlay plotting.
|
|
2
|
+
|
|
3
|
+
Satellite raster as the background colored field, 国控 station observations
|
|
4
|
+
on top as colored dots. Each layer keeps its own colorbar so the two
|
|
5
|
+
quantity scales stay independent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import cartopy.crs as ccrs
|
|
13
|
+
import cartopy.feature as cfeature
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import matplotlib.ticker as mticker
|
|
16
|
+
import numpy as np
|
|
17
|
+
from cartopy.feature import ShapelyFeature
|
|
18
|
+
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
|
|
19
|
+
|
|
20
|
+
from rsplot.plotting.colormaps import (
|
|
21
|
+
STATION_CONCENTRATION_CMAP,
|
|
22
|
+
register_rsplot_colormaps,
|
|
23
|
+
)
|
|
24
|
+
from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
|
|
25
|
+
from rsplot.plotting.station import _build_aqi_cmap, _station_marker_size
|
|
26
|
+
from rsplot.tiles.tianditu import TianDiTuTiles
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
30
|
+
from rsplot.readers.guokong import StationData
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def plot_overlay(
|
|
34
|
+
LON: np.ndarray,
|
|
35
|
+
LAT: np.ndarray,
|
|
36
|
+
grid: np.ndarray,
|
|
37
|
+
data: StationData,
|
|
38
|
+
region: RegionInfo,
|
|
39
|
+
*,
|
|
40
|
+
# raster layer
|
|
41
|
+
raster_cmap: str = "YlOrRd",
|
|
42
|
+
raster_vmin: float = 0.0,
|
|
43
|
+
raster_vmax: float = 20.0,
|
|
44
|
+
raster_label: str = "",
|
|
45
|
+
# station layer
|
|
46
|
+
station_threshold: float | None = None,
|
|
47
|
+
station_label: str = "",
|
|
48
|
+
# shared
|
|
49
|
+
dpi: int = 300,
|
|
50
|
+
basemap: bool | None = None,
|
|
51
|
+
tianditu_key: str | None = None,
|
|
52
|
+
title: str | None = None,
|
|
53
|
+
output: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Draw satellite raster + station scatter on the same axes.
|
|
56
|
+
|
|
57
|
+
The raster uses a reduced alpha so station markers stay legible; when
|
|
58
|
+
a basemap is requested the raster alpha is reduced further. The two
|
|
59
|
+
colorbars share the right side — raster on top, stations below —
|
|
60
|
+
so each quantity keeps its own scale.
|
|
61
|
+
"""
|
|
62
|
+
params = region.params
|
|
63
|
+
use_basemap = basemap if basemap is not None else params.basemap
|
|
64
|
+
register_rsplot_colormaps()
|
|
65
|
+
|
|
66
|
+
# --- Figure setup ---
|
|
67
|
+
if use_basemap and tianditu_key:
|
|
68
|
+
tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
|
|
69
|
+
projection = tiles.crs
|
|
70
|
+
else:
|
|
71
|
+
tiles = None
|
|
72
|
+
projection = ccrs.PlateCarree()
|
|
73
|
+
|
|
74
|
+
fig, ax = plt.subplots(
|
|
75
|
+
figsize=_figsize_for_level(region.level),
|
|
76
|
+
dpi=dpi,
|
|
77
|
+
subplot_kw={"projection": projection},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# --- Raster layer (beneath everything else) ---
|
|
81
|
+
raster_alpha = 0.45 if use_basemap else 0.7
|
|
82
|
+
im = ax.pcolormesh(
|
|
83
|
+
LON,
|
|
84
|
+
LAT,
|
|
85
|
+
grid,
|
|
86
|
+
transform=ccrs.PlateCarree(),
|
|
87
|
+
cmap=raster_cmap,
|
|
88
|
+
vmin=raster_vmin,
|
|
89
|
+
vmax=raster_vmax,
|
|
90
|
+
shading="auto",
|
|
91
|
+
alpha=raster_alpha,
|
|
92
|
+
zorder=1,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# --- Basemap tiles ---
|
|
96
|
+
if tiles is not None:
|
|
97
|
+
ax.add_image(tiles, params.zoom)
|
|
98
|
+
|
|
99
|
+
# --- Detail boundaries (dotted) ---
|
|
100
|
+
if (
|
|
101
|
+
region.detail_boundary_gdf is not None
|
|
102
|
+
and len(region.detail_boundary_gdf) > 0
|
|
103
|
+
):
|
|
104
|
+
ax.add_feature(
|
|
105
|
+
ShapelyFeature(
|
|
106
|
+
region.detail_boundary_gdf.geometry,
|
|
107
|
+
ccrs.PlateCarree(),
|
|
108
|
+
edgecolor="#aaa",
|
|
109
|
+
facecolor="none",
|
|
110
|
+
linewidth=0.25,
|
|
111
|
+
linestyle=":",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# --- Sub-boundaries ---
|
|
116
|
+
if (
|
|
117
|
+
region.sub_boundary_gdf is not None
|
|
118
|
+
and len(region.sub_boundary_gdf) > 0
|
|
119
|
+
):
|
|
120
|
+
is_county_sub = region.level == "city"
|
|
121
|
+
ax.add_feature(
|
|
122
|
+
ShapelyFeature(
|
|
123
|
+
region.sub_boundary_gdf.geometry,
|
|
124
|
+
ccrs.PlateCarree(),
|
|
125
|
+
edgecolor="#888" if not is_county_sub else "#666",
|
|
126
|
+
facecolor="none",
|
|
127
|
+
linewidth=0.4 if not is_county_sub else 0.6,
|
|
128
|
+
linestyle="-" if not is_county_sub else "--",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
_add_sub_labels(ax, region)
|
|
132
|
+
|
|
133
|
+
# --- Main boundary (red) ---
|
|
134
|
+
if region.main_boundary_gdf is not None:
|
|
135
|
+
ax.add_feature(
|
|
136
|
+
ShapelyFeature(
|
|
137
|
+
region.main_boundary_gdf.geometry,
|
|
138
|
+
ccrs.PlateCarree(),
|
|
139
|
+
edgecolor="red",
|
|
140
|
+
facecolor="none",
|
|
141
|
+
linewidth=params.boundary_linewidth,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# --- Station overlay ---
|
|
146
|
+
valid = np.isfinite(data.values)
|
|
147
|
+
lon = data.lon[valid]
|
|
148
|
+
lat = data.lat[valid]
|
|
149
|
+
vals = data.values[valid]
|
|
150
|
+
|
|
151
|
+
sc = None
|
|
152
|
+
if len(vals) > 0:
|
|
153
|
+
marker_size = _station_marker_size(region.level)
|
|
154
|
+
|
|
155
|
+
is_aqi = data.var_name == "AQI"
|
|
156
|
+
if is_aqi:
|
|
157
|
+
cmap, norm = _build_aqi_cmap()
|
|
158
|
+
sc = ax.scatter(
|
|
159
|
+
lon, lat, c=vals, cmap=cmap, norm=norm,
|
|
160
|
+
s=marker_size, edgecolors="#111", linewidths=0.4,
|
|
161
|
+
transform=ccrs.PlateCarree(), zorder=5,
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
p5 = float(np.nanpercentile(vals, 5))
|
|
165
|
+
p95 = float(np.nanpercentile(vals, 95))
|
|
166
|
+
sc = ax.scatter(
|
|
167
|
+
lon, lat, c=vals, cmap=STATION_CONCENTRATION_CMAP,
|
|
168
|
+
vmin=p5, vmax=p95,
|
|
169
|
+
s=marker_size, edgecolors="#111", linewidths=0.4,
|
|
170
|
+
transform=ccrs.PlateCarree(), zorder=5,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Red circle for exceedance
|
|
174
|
+
if station_threshold is not None:
|
|
175
|
+
exceed_mask = vals > station_threshold
|
|
176
|
+
if exceed_mask.any():
|
|
177
|
+
ax.scatter(
|
|
178
|
+
lon[exceed_mask],
|
|
179
|
+
lat[exceed_mask],
|
|
180
|
+
s=marker_size * 3,
|
|
181
|
+
facecolors="none",
|
|
182
|
+
edgecolors="red",
|
|
183
|
+
linewidths=1.5,
|
|
184
|
+
transform=ccrs.PlateCarree(),
|
|
185
|
+
zorder=6,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# --- Map decorations ---
|
|
189
|
+
if not use_basemap:
|
|
190
|
+
ax.coastlines(linewidth=0.5, color="#333")
|
|
191
|
+
ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
|
|
192
|
+
|
|
193
|
+
lon_min, lon_max, lat_min, lat_max = region.extent
|
|
194
|
+
pad = params.pad
|
|
195
|
+
ax.set_extent(
|
|
196
|
+
[lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
|
|
197
|
+
crs=ccrs.PlateCarree(),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
gl = ax.gridlines(
|
|
201
|
+
draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
|
|
202
|
+
)
|
|
203
|
+
gl.top_labels = gl.right_labels = False
|
|
204
|
+
gl.xformatter = LONGITUDE_FORMATTER
|
|
205
|
+
gl.yformatter = LATITUDE_FORMATTER
|
|
206
|
+
gl.xlocator = mticker.MultipleLocator(params.grid_step)
|
|
207
|
+
gl.ylocator = mticker.MultipleLocator(params.grid_step)
|
|
208
|
+
|
|
209
|
+
# --- Twin colorbars, hugging the right edge of the map ---
|
|
210
|
+
# Using ax.inset_axes keeps the bars next to the plot instead of
|
|
211
|
+
# letting them float out to the figure corners. Top half = raster,
|
|
212
|
+
# bottom half = station, with a small gap between them.
|
|
213
|
+
# Coords are in axes-fraction: [x0, y0, width, height].
|
|
214
|
+
cax_raster = ax.inset_axes([1.03, 0.54, 0.025, 0.42])
|
|
215
|
+
cb_raster = fig.colorbar(im, cax=cax_raster)
|
|
216
|
+
if raster_label:
|
|
217
|
+
cb_raster.set_label(raster_label, fontsize=9)
|
|
218
|
+
cb_raster.ax.tick_params(labelsize=8)
|
|
219
|
+
|
|
220
|
+
if sc is not None:
|
|
221
|
+
cax_station = ax.inset_axes([1.03, 0.04, 0.025, 0.42])
|
|
222
|
+
cb_station = fig.colorbar(sc, cax=cax_station)
|
|
223
|
+
if station_label:
|
|
224
|
+
cb_station.set_label(station_label, fontsize=9)
|
|
225
|
+
cb_station.ax.tick_params(labelsize=8)
|
|
226
|
+
|
|
227
|
+
# Title
|
|
228
|
+
if title is None:
|
|
229
|
+
title = f"{region.name} · raster + stations"
|
|
230
|
+
ax.set_title(title, fontsize=14)
|
|
231
|
+
|
|
232
|
+
# Reserve room on the right for the two inset colorbars — plain
|
|
233
|
+
# tight_layout() ignores inset_axes and would crop them off.
|
|
234
|
+
fig.tight_layout(rect=[0, 0, 0.9, 1])
|
|
235
|
+
|
|
236
|
+
# --- Output ---
|
|
237
|
+
if output:
|
|
238
|
+
fig.savefig(output, dpi=dpi, bbox_inches="tight")
|
|
239
|
+
plt.close(fig)
|
|
240
|
+
else:
|
|
241
|
+
plt.show()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Core raster plotting pipeline.
|
|
2
|
+
|
|
3
|
+
Unified entry point for all administrative levels. Takes gridded data
|
|
4
|
+
and RegionInfo, produces a publication-quality map.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import cartopy.crs as ccrs
|
|
12
|
+
import cartopy.feature as cfeature
|
|
13
|
+
import matplotlib.patheffects as pe
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import matplotlib.ticker as mticker
|
|
16
|
+
import numpy as np
|
|
17
|
+
from cartopy.feature import ShapelyFeature
|
|
18
|
+
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
|
|
19
|
+
|
|
20
|
+
from rsplot.plotting.colormaps import register_rsplot_colormaps
|
|
21
|
+
from rsplot.tiles.tianditu import TianDiTuTiles
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def plot_raster(
|
|
28
|
+
LON: np.ndarray,
|
|
29
|
+
LAT: np.ndarray,
|
|
30
|
+
grid: np.ndarray,
|
|
31
|
+
region: RegionInfo,
|
|
32
|
+
*,
|
|
33
|
+
# overridable params
|
|
34
|
+
dpi: int = 200,
|
|
35
|
+
cmap: str = "turbo",
|
|
36
|
+
vmin: float = 0.0,
|
|
37
|
+
vmax: float = 20.0,
|
|
38
|
+
basemap: bool | None = None,
|
|
39
|
+
tianditu_key: str | None = None,
|
|
40
|
+
title: str | None = None,
|
|
41
|
+
output: str | None = None,
|
|
42
|
+
colorbar_label: str = "NO2 VCD (x10^15 molec/cm2)",
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Draw a raster map for the given region.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
LON, LAT, grid : 2-D arrays from gridding pipeline.
|
|
49
|
+
region : RegionInfo from resolve_region.
|
|
50
|
+
basemap : override region.params.basemap if not None.
|
|
51
|
+
output : save to file path. If None, plt.show().
|
|
52
|
+
"""
|
|
53
|
+
params = region.params
|
|
54
|
+
use_basemap = basemap if basemap is not None else params.basemap
|
|
55
|
+
register_rsplot_colormaps()
|
|
56
|
+
|
|
57
|
+
# --- Figure setup ---
|
|
58
|
+
if use_basemap and tianditu_key:
|
|
59
|
+
tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
|
|
60
|
+
projection = tiles.crs
|
|
61
|
+
else:
|
|
62
|
+
tiles = None
|
|
63
|
+
projection = ccrs.PlateCarree()
|
|
64
|
+
|
|
65
|
+
fig, ax = plt.subplots(
|
|
66
|
+
figsize=_figsize_for_level(region.level),
|
|
67
|
+
dpi=dpi,
|
|
68
|
+
subplot_kw={"projection": projection},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# --- Raster layer ---
|
|
72
|
+
alpha = 0.55 if use_basemap else 1.0
|
|
73
|
+
im = ax.pcolormesh(
|
|
74
|
+
LON,
|
|
75
|
+
LAT,
|
|
76
|
+
grid,
|
|
77
|
+
transform=ccrs.PlateCarree(),
|
|
78
|
+
cmap=cmap,
|
|
79
|
+
vmin=vmin,
|
|
80
|
+
vmax=vmax,
|
|
81
|
+
shading="auto",
|
|
82
|
+
alpha=alpha,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# --- Basemap tiles (beneath boundaries, above raster) ---
|
|
86
|
+
if tiles is not None:
|
|
87
|
+
ax.add_image(tiles, params.zoom)
|
|
88
|
+
|
|
89
|
+
# --- Detail boundaries (counties in province view, dotted) ---
|
|
90
|
+
if (
|
|
91
|
+
region.detail_boundary_gdf is not None
|
|
92
|
+
and len(region.detail_boundary_gdf) > 0
|
|
93
|
+
):
|
|
94
|
+
ax.add_feature(
|
|
95
|
+
ShapelyFeature(
|
|
96
|
+
region.detail_boundary_gdf.geometry,
|
|
97
|
+
ccrs.PlateCarree(),
|
|
98
|
+
edgecolor="#aaa",
|
|
99
|
+
facecolor="none",
|
|
100
|
+
linewidth=0.25,
|
|
101
|
+
linestyle=":",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# --- Sub-boundaries (grey) ---
|
|
106
|
+
if (
|
|
107
|
+
region.sub_boundary_gdf is not None
|
|
108
|
+
and len(region.sub_boundary_gdf) > 0
|
|
109
|
+
):
|
|
110
|
+
is_county_sub = region.level == "city"
|
|
111
|
+
ax.add_feature(
|
|
112
|
+
ShapelyFeature(
|
|
113
|
+
region.sub_boundary_gdf.geometry,
|
|
114
|
+
ccrs.PlateCarree(),
|
|
115
|
+
edgecolor="#888" if not is_county_sub else "#666",
|
|
116
|
+
facecolor="none",
|
|
117
|
+
linewidth=0.4 if not is_county_sub else 0.6,
|
|
118
|
+
linestyle="-" if not is_county_sub else "--",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
# Sub-boundary labels
|
|
122
|
+
_add_sub_labels(ax, region)
|
|
123
|
+
|
|
124
|
+
# --- Main boundary (red) ---
|
|
125
|
+
if region.main_boundary_gdf is not None:
|
|
126
|
+
ax.add_feature(
|
|
127
|
+
ShapelyFeature(
|
|
128
|
+
region.main_boundary_gdf.geometry,
|
|
129
|
+
ccrs.PlateCarree(),
|
|
130
|
+
edgecolor="red",
|
|
131
|
+
facecolor="none",
|
|
132
|
+
linewidth=params.boundary_linewidth,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# --- Map decorations ---
|
|
137
|
+
if not use_basemap:
|
|
138
|
+
ax.coastlines(linewidth=0.5, color="#333")
|
|
139
|
+
ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
|
|
140
|
+
|
|
141
|
+
# Set extent with padding
|
|
142
|
+
lon_min, lon_max, lat_min, lat_max = region.extent
|
|
143
|
+
pad = params.pad
|
|
144
|
+
ax.set_extent(
|
|
145
|
+
[lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
|
|
146
|
+
crs=ccrs.PlateCarree(),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Gridlines
|
|
150
|
+
gl = ax.gridlines(
|
|
151
|
+
draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
|
|
152
|
+
)
|
|
153
|
+
gl.top_labels = gl.right_labels = False
|
|
154
|
+
gl.xformatter = LONGITUDE_FORMATTER
|
|
155
|
+
gl.yformatter = LATITUDE_FORMATTER
|
|
156
|
+
gl.xlocator = mticker.MultipleLocator(params.grid_step)
|
|
157
|
+
gl.ylocator = mticker.MultipleLocator(params.grid_step)
|
|
158
|
+
|
|
159
|
+
# Colorbar
|
|
160
|
+
shrink = 0.6 if region.level in ("key_region", "province") else 0.7
|
|
161
|
+
plt.colorbar(im, ax=ax, shrink=shrink, pad=0.05).set_label(colorbar_label)
|
|
162
|
+
|
|
163
|
+
# Title
|
|
164
|
+
if title is None:
|
|
165
|
+
title = f"TROPOMI - {region.name}"
|
|
166
|
+
ax.set_title(title, fontsize=14)
|
|
167
|
+
|
|
168
|
+
plt.tight_layout()
|
|
169
|
+
|
|
170
|
+
# --- Output ---
|
|
171
|
+
if output:
|
|
172
|
+
fig.savefig(output, dpi=dpi, bbox_inches="tight")
|
|
173
|
+
plt.close(fig)
|
|
174
|
+
else:
|
|
175
|
+
plt.show()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _figsize_for_level(level: str) -> tuple[float, float]:
|
|
179
|
+
"""Choose figure size based on admin level."""
|
|
180
|
+
return {
|
|
181
|
+
"country": (14, 12),
|
|
182
|
+
"key_region": (12, 10),
|
|
183
|
+
"province": (10, 10),
|
|
184
|
+
"city": (10, 8),
|
|
185
|
+
"county": (8, 7),
|
|
186
|
+
}.get(level, (10, 8))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _add_sub_labels(ax, region: RegionInfo) -> None:
|
|
190
|
+
"""Add name labels for sub-boundary features."""
|
|
191
|
+
gdf = region.sub_boundary_gdf
|
|
192
|
+
if gdf is None or len(gdf) == 0:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
name_field = region.sub_name_field
|
|
196
|
+
fontsize = region.params.label_fontsize
|
|
197
|
+
|
|
198
|
+
for _, row in gdf.iterrows():
|
|
199
|
+
pt = row.geometry.representative_point()
|
|
200
|
+
# For key_region/province level: only label cities inside the region
|
|
201
|
+
if region.level in (
|
|
202
|
+
"key_region",
|
|
203
|
+
"province",
|
|
204
|
+
) and not region.geometry.contains(pt):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
label = row[name_field]
|
|
208
|
+
# Strip "市" suffix for city-level labels in province/key_region view
|
|
209
|
+
if region.level in ("key_region", "province"):
|
|
210
|
+
label = label.replace("市", "")
|
|
211
|
+
|
|
212
|
+
ax.text(
|
|
213
|
+
pt.x,
|
|
214
|
+
pt.y,
|
|
215
|
+
label,
|
|
216
|
+
transform=ccrs.PlateCarree(),
|
|
217
|
+
fontsize=fontsize,
|
|
218
|
+
ha="center",
|
|
219
|
+
va="center",
|
|
220
|
+
color="#222",
|
|
221
|
+
path_effects=[pe.withStroke(linewidth=2, foreground="white")],
|
|
222
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Station scatter plot pipeline.
|
|
2
|
+
|
|
3
|
+
Plots ground monitoring station data as colored dots on a map,
|
|
4
|
+
with red circles for stations exceeding air quality limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import cartopy.crs as ccrs
|
|
12
|
+
import cartopy.feature as cfeature
|
|
13
|
+
import matplotlib.colors as mcolors
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import matplotlib.ticker as mticker
|
|
16
|
+
import numpy as np
|
|
17
|
+
from cartopy.feature import ShapelyFeature
|
|
18
|
+
from cartopy.mpl.gridliner import LATITUDE_FORMATTER, LONGITUDE_FORMATTER
|
|
19
|
+
|
|
20
|
+
from rsplot.config import AQI_COLORS
|
|
21
|
+
from rsplot.plotting.raster import _add_sub_labels, _figsize_for_level
|
|
22
|
+
from rsplot.tiles.tianditu import TianDiTuTiles
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from rsplot.geo.boundaries import RegionInfo
|
|
26
|
+
from rsplot.readers.guokong import StationData
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_aqi_cmap() -> tuple[mcolors.ListedColormap, mcolors.BoundaryNorm]:
|
|
30
|
+
"""Build a discrete colormap matching official AQI color bands."""
|
|
31
|
+
bounds = [0] + [b for b, _ in AQI_COLORS]
|
|
32
|
+
colors = [c for _, c in AQI_COLORS]
|
|
33
|
+
cmap = mcolors.ListedColormap(colors)
|
|
34
|
+
norm = mcolors.BoundaryNorm(bounds, cmap.N)
|
|
35
|
+
return cmap, norm
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _station_marker_size(level: str) -> int:
|
|
39
|
+
"""Marker area by region level, in points squared."""
|
|
40
|
+
return {
|
|
41
|
+
"country": 18,
|
|
42
|
+
"key_region": 32,
|
|
43
|
+
"province": 46,
|
|
44
|
+
"city": 76,
|
|
45
|
+
"county": 110,
|
|
46
|
+
}.get(level, 46)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def plot_station(
|
|
50
|
+
data: StationData,
|
|
51
|
+
region: RegionInfo,
|
|
52
|
+
*,
|
|
53
|
+
threshold: float | None = None,
|
|
54
|
+
dpi: int = 300,
|
|
55
|
+
basemap: bool | None = None,
|
|
56
|
+
tianditu_key: str | None = None,
|
|
57
|
+
title: str | None = None,
|
|
58
|
+
output: str | None = None,
|
|
59
|
+
colorbar_label: str = "",
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Draw a station scatter map for the given region.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
data : StationData from guokong reader.
|
|
66
|
+
region : RegionInfo from resolve_region.
|
|
67
|
+
threshold : value above which a red exceedance circle is drawn.
|
|
68
|
+
"""
|
|
69
|
+
params = region.params
|
|
70
|
+
use_basemap = basemap if basemap is not None else params.basemap
|
|
71
|
+
|
|
72
|
+
# --- Figure setup ---
|
|
73
|
+
if use_basemap and tianditu_key:
|
|
74
|
+
tiles = TianDiTuTiles(tk=tianditu_key, layer="img")
|
|
75
|
+
projection = tiles.crs
|
|
76
|
+
else:
|
|
77
|
+
tiles = None
|
|
78
|
+
projection = ccrs.PlateCarree()
|
|
79
|
+
|
|
80
|
+
fig, ax = plt.subplots(
|
|
81
|
+
figsize=_figsize_for_level(region.level),
|
|
82
|
+
dpi=dpi,
|
|
83
|
+
subplot_kw={"projection": projection},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# --- Basemap tiles ---
|
|
87
|
+
if tiles is not None:
|
|
88
|
+
ax.add_image(tiles, params.zoom)
|
|
89
|
+
|
|
90
|
+
# --- Detail boundaries (counties in province view, dotted) ---
|
|
91
|
+
if (
|
|
92
|
+
region.detail_boundary_gdf is not None
|
|
93
|
+
and len(region.detail_boundary_gdf) > 0
|
|
94
|
+
):
|
|
95
|
+
ax.add_feature(
|
|
96
|
+
ShapelyFeature(
|
|
97
|
+
region.detail_boundary_gdf.geometry,
|
|
98
|
+
ccrs.PlateCarree(),
|
|
99
|
+
edgecolor="#aaa",
|
|
100
|
+
facecolor="none",
|
|
101
|
+
linewidth=0.25,
|
|
102
|
+
linestyle=":",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# --- Sub-boundaries (grey) ---
|
|
107
|
+
if (
|
|
108
|
+
region.sub_boundary_gdf is not None
|
|
109
|
+
and len(region.sub_boundary_gdf) > 0
|
|
110
|
+
):
|
|
111
|
+
is_county_sub = region.level == "city"
|
|
112
|
+
ax.add_feature(
|
|
113
|
+
ShapelyFeature(
|
|
114
|
+
region.sub_boundary_gdf.geometry,
|
|
115
|
+
ccrs.PlateCarree(),
|
|
116
|
+
edgecolor="#888" if not is_county_sub else "#666",
|
|
117
|
+
facecolor="none",
|
|
118
|
+
linewidth=0.4 if not is_county_sub else 0.6,
|
|
119
|
+
linestyle="-" if not is_county_sub else "--",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
_add_sub_labels(ax, region)
|
|
123
|
+
|
|
124
|
+
# --- Main boundary (red) ---
|
|
125
|
+
if region.main_boundary_gdf is not None:
|
|
126
|
+
ax.add_feature(
|
|
127
|
+
ShapelyFeature(
|
|
128
|
+
region.main_boundary_gdf.geometry,
|
|
129
|
+
ccrs.PlateCarree(),
|
|
130
|
+
edgecolor="red",
|
|
131
|
+
facecolor="none",
|
|
132
|
+
linewidth=params.boundary_linewidth,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# --- Filter to valid data ---
|
|
137
|
+
valid = np.isfinite(data.values)
|
|
138
|
+
lon = data.lon[valid]
|
|
139
|
+
lat = data.lat[valid]
|
|
140
|
+
vals = data.values[valid]
|
|
141
|
+
|
|
142
|
+
if len(vals) == 0:
|
|
143
|
+
ax.text(
|
|
144
|
+
0.5, 0.5, "No valid data",
|
|
145
|
+
transform=ax.transAxes, ha="center", fontsize=14,
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
# --- Marker size by level ---
|
|
149
|
+
marker_size = _station_marker_size(region.level)
|
|
150
|
+
|
|
151
|
+
# --- Colormap / norm ---
|
|
152
|
+
is_aqi = data.var_name == "AQI"
|
|
153
|
+
if is_aqi:
|
|
154
|
+
cmap, norm = _build_aqi_cmap()
|
|
155
|
+
sc = ax.scatter(
|
|
156
|
+
lon, lat, c=vals, cmap=cmap, norm=norm,
|
|
157
|
+
s=marker_size, edgecolors="#333", linewidths=0.3,
|
|
158
|
+
transform=ccrs.PlateCarree(), zorder=5,
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
p5 = float(np.nanpercentile(vals, 5))
|
|
162
|
+
p95 = float(np.nanpercentile(vals, 95))
|
|
163
|
+
sc = ax.scatter(
|
|
164
|
+
lon, lat, c=vals, cmap="YlOrRd",
|
|
165
|
+
vmin=p5, vmax=p95,
|
|
166
|
+
s=marker_size, edgecolors="#333", linewidths=0.3,
|
|
167
|
+
transform=ccrs.PlateCarree(), zorder=5,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# --- Red circles for exceedance ---
|
|
171
|
+
if threshold is not None:
|
|
172
|
+
exceed_mask = vals > threshold
|
|
173
|
+
if exceed_mask.any():
|
|
174
|
+
ax.scatter(
|
|
175
|
+
lon[exceed_mask],
|
|
176
|
+
lat[exceed_mask],
|
|
177
|
+
s=marker_size * 3,
|
|
178
|
+
facecolors="none",
|
|
179
|
+
edgecolors="red",
|
|
180
|
+
linewidths=1.5,
|
|
181
|
+
transform=ccrs.PlateCarree(),
|
|
182
|
+
zorder=6,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# --- Colorbar ---
|
|
186
|
+
shrink = 0.6 if region.level in ("country", "key_region", "province") else 0.7
|
|
187
|
+
cb = plt.colorbar(sc, ax=ax, shrink=shrink, pad=0.05)
|
|
188
|
+
if colorbar_label:
|
|
189
|
+
cb.set_label(colorbar_label)
|
|
190
|
+
|
|
191
|
+
# --- Map decorations ---
|
|
192
|
+
if not use_basemap:
|
|
193
|
+
ax.coastlines(linewidth=0.5, color="#333")
|
|
194
|
+
ax.add_feature(cfeature.OCEAN, facecolor="#e6f2ff", zorder=0)
|
|
195
|
+
|
|
196
|
+
lon_min, lon_max, lat_min, lat_max = region.extent
|
|
197
|
+
pad = params.pad
|
|
198
|
+
ax.set_extent(
|
|
199
|
+
[lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad],
|
|
200
|
+
crs=ccrs.PlateCarree(),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Gridlines
|
|
204
|
+
gl = ax.gridlines(
|
|
205
|
+
draw_labels=True, linewidth=0.2, linestyle="--", alpha=0.5
|
|
206
|
+
)
|
|
207
|
+
gl.top_labels = gl.right_labels = False
|
|
208
|
+
gl.xformatter = LONGITUDE_FORMATTER
|
|
209
|
+
gl.yformatter = LATITUDE_FORMATTER
|
|
210
|
+
gl.xlocator = mticker.MultipleLocator(params.grid_step)
|
|
211
|
+
gl.ylocator = mticker.MultipleLocator(params.grid_step)
|
|
212
|
+
|
|
213
|
+
# Title
|
|
214
|
+
if title is None:
|
|
215
|
+
title = f"{data.var_name} - {region.name}"
|
|
216
|
+
ax.set_title(title, fontsize=14)
|
|
217
|
+
|
|
218
|
+
plt.tight_layout()
|
|
219
|
+
|
|
220
|
+
# --- Output ---
|
|
221
|
+
if output:
|
|
222
|
+
fig.savefig(output, dpi=dpi, bbox_inches="tight")
|
|
223
|
+
plt.close(fig)
|
|
224
|
+
else:
|
|
225
|
+
plt.show()
|