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.
@@ -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()