rsplot 0.3.1.dev3__py3-none-any.whl → 0.3.2.dev4__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/readers/gems.py ADDED
@@ -0,0 +1,426 @@
1
+ """GEMS L2 scan readers; UTC archive names and China-time requests.
2
+
3
+ Daily ``read`` returns concatenated samples for BaseReader compatibility.
4
+ Scientific daily means use ``iter_scans`` and equal-scan grid averaging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any, ClassVar
15
+ from zoneinfo import ZoneInfo
16
+
17
+ import numpy as np
18
+ from netCDF4 import Dataset
19
+
20
+ from rsplot.readers.base import BaseReader, SwathData
21
+
22
+ # Numerical analysis spacing, not native satellite resolution.
23
+ GEMS_ANALYSIS_RESOLUTION = 0.05
24
+
25
+ CHINA_TIME = ZoneInfo("Asia/Shanghai")
26
+ EPOCH = datetime(2000, 1, 1, 12, tzinfo=timezone.utc)
27
+ FILE_RE = re.compile(r"^GK2_GEMS_L2_(\d{8})_(\d{4})_(NO2|HCHO|O3P)_.+\.nc$")
28
+
29
+
30
+ def parse_gems_time(value: str) -> datetime:
31
+ """Parse an exact nominal scan time in Asia/Shanghai."""
32
+ if not value.isdigit() or len(value) != 12:
33
+ raise ValueError("GEMS 扫描时间应为北京时间 YYYYMMDDHHMM。")
34
+ try:
35
+ return datetime.strptime(value, "%Y%m%d%H%M").replace(
36
+ tzinfo=CHINA_TIME
37
+ )
38
+ except ValueError as error:
39
+ raise ValueError(f"无效 GEMS 扫描时间: {value}") from error
40
+
41
+
42
+ def match_station_hour(
43
+ scan: str, override: str | None = None
44
+ ) -> tuple[str, dict[str, Any]]:
45
+ """Match a scan to an hour, or pair a daily mean with an explicit hour."""
46
+ from rsplot.temporal import adapt_hourly_time, parse_yyyymmdd
47
+
48
+ if len(scan) == 8:
49
+ parse_yyyymmdd(scan, "GEMS 日均日期")
50
+ if override is None:
51
+ raise ValueError(
52
+ "GEMS 日均叠加需通过 --station-datetime YYYYMMDDHH "
53
+ "显式指定站点时次。"
54
+ )
55
+ hour = adapt_hourly_time(override).input_time.strftime("%Y%m%d%H")
56
+ return hour, {
57
+ "satellite_date": scan,
58
+ "satellite_aggregation": "daily_mean",
59
+ "station_datetime": hour,
60
+ "timezone": "Asia/Shanghai",
61
+ "difference_minutes": None,
62
+ "manual_override": True,
63
+ "reference": "daily_mean_with_explicit_station_hour",
64
+ }
65
+
66
+ time = parse_gems_time(scan)
67
+ if override is None:
68
+ station = (time + timedelta(minutes=30)).replace(minute=0)
69
+ else:
70
+ station = adapt_hourly_time(override).input_time
71
+ hour = station.strftime("%Y%m%d%H")
72
+ return hour, {
73
+ "scan_datetime": scan,
74
+ "station_datetime": hour,
75
+ "timezone": "Asia/Shanghai",
76
+ "difference_minutes": (station - time).total_seconds() / 60,
77
+ "manual_override": override is not None,
78
+ "reference": "nominal_scan_time",
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class GemsSpec:
84
+ file_product: str
85
+ value: str
86
+ cloud: str
87
+ scale: float = 1e-15
88
+ profile: bool = False
89
+
90
+
91
+ class GemsReader(BaseReader):
92
+ """Read required fields, filter each scan, and retain provenance."""
93
+
94
+ spec: ClassVar[GemsSpec]
95
+ uses_footprints = True
96
+ cloud_fraction_max: float = 0.5
97
+
98
+ def discover(
99
+ self, data_dir: str, request: str
100
+ ) -> list[tuple[Path, datetime]]:
101
+ exact = len(request) == 12
102
+ if exact:
103
+ start = parse_gems_time(request).replace(hour=0, minute=0)
104
+ else:
105
+ if len(request) != 8 or not request.isdigit():
106
+ raise ValueError("GEMS 日期应为 YYYYMMDD 或 YYYYMMDDHHMM。")
107
+ try:
108
+ start = datetime.strptime(request, "%Y%m%d").replace(
109
+ tzinfo=CHINA_TIME
110
+ )
111
+ except ValueError as error:
112
+ raise ValueError(f"无效 GEMS 日期: {request}") from error
113
+ end = start + timedelta(days=1)
114
+ utc_days = {
115
+ start.astimezone(timezone.utc).date(),
116
+ (end - timedelta(microseconds=1)).astimezone(timezone.utc).date(),
117
+ }
118
+ files = []
119
+ for day in sorted(utc_days):
120
+ folder = Path(data_dir) / day.strftime("%Y%m") / day.strftime("%d")
121
+ for path in sorted(folder.glob("GK2_GEMS_L2_*.nc")):
122
+ match = FILE_RE.match(path.name)
123
+ if not match or match[3] != self.spec.file_product:
124
+ continue
125
+ nominal = datetime.strptime(
126
+ match[1] + match[2], "%Y%m%d%H%M"
127
+ ).replace(tzinfo=timezone.utc)
128
+ if start <= nominal < end:
129
+ files.append((path, nominal))
130
+ files.sort(key=lambda item: (item[1], str(item[0])))
131
+ selected = (
132
+ [item for item in files if item[1] == parse_gems_time(request)]
133
+ if exact
134
+ else files
135
+ )
136
+ if not selected:
137
+ available = sorted(
138
+ {
139
+ time.astimezone(CHINA_TIME).strftime("%Y%m%d%H%M")
140
+ for _, time in files
141
+ }
142
+ )
143
+ raise FileNotFoundError(
144
+ f"未找到 GEMS {request};当日可用扫描(北京时间): "
145
+ + (", ".join(available) or "无")
146
+ )
147
+ times = [time for _, time in selected]
148
+ if len(set(times)) != len(times):
149
+ raise ValueError(
150
+ "GEMS 同一标称时次存在多个文件,请使用仅含一个版本的数据目录。"
151
+ )
152
+ return selected
153
+
154
+ def iter_scans(
155
+ self,
156
+ data_dir: str,
157
+ date: str,
158
+ extent: tuple[float, float, float, float],
159
+ qa_threshold: float = 0.5,
160
+ ) -> Iterator[SwathData]:
161
+ """Yield empty diagnostic batches for unreadable files as well."""
162
+ for path, nominal in self.discover(data_dir, date):
163
+ try:
164
+ yield self.read_file(path, nominal, extent)
165
+ except (
166
+ OSError,
167
+ ValueError,
168
+ KeyError,
169
+ IndexError,
170
+ AttributeError,
171
+ RuntimeError,
172
+ ) as error:
173
+ empty = np.empty(0, dtype=float)
174
+ yield SwathData(
175
+ empty,
176
+ empty,
177
+ empty,
178
+ 0,
179
+ 1,
180
+ 1,
181
+ {
182
+ "file": str(path),
183
+ "nominal_utc": nominal.isoformat(),
184
+ "nominal_local": nominal.astimezone(
185
+ CHINA_TIME
186
+ ).isoformat(),
187
+ "error": str(error).splitlines()[0],
188
+ },
189
+ )
190
+
191
+ def read(
192
+ self,
193
+ data_dir: str,
194
+ date: str,
195
+ extent: tuple[float, float, float, float],
196
+ qa_threshold: float = 0.5,
197
+ ) -> SwathData:
198
+ scans = list(self.iter_scans(data_dir, date, extent, qa_threshold))
199
+ valid = [scan for scan in scans if scan.n_pixels]
200
+ if not valid:
201
+ raise ValueError(
202
+ f"GEMS {date} 区域内无有效像元: "
203
+ + str([scan.metadata for scan in scans])
204
+ )
205
+ return SwathData(
206
+ np.concatenate([scan.lon for scan in valid]),
207
+ np.concatenate([scan.lat for scan in valid]),
208
+ np.concatenate([scan.values for scan in valid]),
209
+ sum(scan.n_pixels for scan in scans),
210
+ len(scans),
211
+ sum(scan.n_broken for scan in scans),
212
+ {"scans": [scan.metadata for scan in scans]},
213
+ np.concatenate([scan.corner_lon for scan in valid]),
214
+ np.concatenate([scan.corner_lat for scan in valid]),
215
+ )
216
+
217
+ def read_file(
218
+ self,
219
+ path: Path,
220
+ nominal: datetime,
221
+ extent: tuple[float, float, float, float],
222
+ ) -> SwathData:
223
+ spec = self.spec
224
+ geo = "geolocation" if spec.profile else "Geolocation Fields"
225
+ data = "product" if spec.profile else "Data Fields"
226
+ names = (
227
+ (
228
+ "longitude",
229
+ "latitude",
230
+ "solar_zenith_angle",
231
+ "viewing_zenith_angle",
232
+ )
233
+ if spec.profile
234
+ else (
235
+ "Longitude",
236
+ "Latitude",
237
+ "SolarZenithAngle",
238
+ "ViewingZenithAngle",
239
+ )
240
+ )
241
+ flag_path = data + (
242
+ "/final_algorithm_flags"
243
+ if spec.profile
244
+ else "/FinalAlgorithmFlags"
245
+ )
246
+ with Dataset(path) as ds:
247
+
248
+ def array(name: str) -> np.ndarray:
249
+ return np.ma.asarray(ds[name][:], dtype=float).filled(np.nan)
250
+
251
+ lon, lat, sza, vza = [array(f"{geo}/{name}") for name in names]
252
+ value_var = ds[f"{data}/{spec.value}"]
253
+ units = value_var.getncattr("units")
254
+ expected = "DU" if spec.profile else "molecules cm-2"
255
+ if units != expected:
256
+ raise ValueError(
257
+ f"{spec.value} 单位不匹配: {units!r},预期 {expected}"
258
+ )
259
+ value = array(f"{data}/{spec.value}")
260
+ flags = array(flag_path)
261
+ cloud = array(spec.cloud)
262
+ if value.ndim != 2 or any(
263
+ a.shape != value.shape
264
+ for a in (lon, lat, sza, vza, flags, cloud)
265
+ ):
266
+ raise ValueError(
267
+ "GEMS 经纬度、数值和质量变量必须为同形状二维数组。"
268
+ )
269
+ from rsplot.geo.footprints import (
270
+ inferred_corner_grid,
271
+ pixel_corners,
272
+ )
273
+
274
+ valid_geo = (
275
+ np.isfinite(lon)
276
+ & np.isfinite(lat)
277
+ & (np.abs(lon) <= 180)
278
+ & (np.abs(lat) <= 90)
279
+ )
280
+ lon = np.where(valid_geo, lon, np.nan)
281
+ lat = np.where(valid_geo, lat, np.nan)
282
+ if (
283
+ "CornerLongitude" in ds[geo].variables
284
+ and "CornerLatitude" in ds[geo].variables
285
+ ):
286
+ corner_lon = array(f"{geo}/CornerLongitude")
287
+ corner_lat = array(f"{geo}/CornerLatitude")
288
+ footprint_source = "file_corner_coordinates"
289
+ else:
290
+ corner_lon = inferred_corner_grid(lon)
291
+ corner_lat = inferred_corner_grid(lat)
292
+ footprint_source = "estimated_from_unfiltered_native_centers"
293
+ expected_shape = (value.shape[0] + 1, value.shape[1] + 1)
294
+ if (
295
+ corner_lon.shape != expected_shape
296
+ or corner_lat.shape != expected_shape
297
+ ):
298
+ raise ValueError("GEMS 像元角点网格形状不匹配。")
299
+ corner_lon, corner_lat = (
300
+ pixel_corners(corner_lon),
301
+ pixel_corners(corner_lat),
302
+ )
303
+ footprint_valid = (
304
+ np.isfinite(corner_lon).all(axis=-1)
305
+ & np.isfinite(corner_lat).all(axis=-1)
306
+ & (np.abs(corner_lon) <= 180).all(axis=-1)
307
+ & (np.abs(corner_lat) <= 90).all(axis=-1)
308
+ )
309
+ west, east, south, north = extent
310
+ mask = (
311
+ np.isfinite(value)
312
+ & (np.abs(value) < 1e29)
313
+ & np.isfinite(lon)
314
+ & np.isfinite(lat)
315
+ & footprint_valid
316
+ & (corner_lon.max(axis=-1) > west)
317
+ & (corner_lon.min(axis=-1) < east)
318
+ & (corner_lat.max(axis=-1) > south)
319
+ & (corner_lat.min(axis=-1) < north)
320
+ & (np.abs(lon) <= 180)
321
+ & (np.abs(lat) <= 90)
322
+ & (flags == 0)
323
+ & np.isfinite(sza)
324
+ & (sza >= 0)
325
+ & (sza < 70)
326
+ & np.isfinite(vza)
327
+ & (vza >= 0)
328
+ & (vza < 70)
329
+ & np.isfinite(cloud)
330
+ & (cloud >= 0)
331
+ & (cloud <= self.cloud_fraction_max)
332
+ )
333
+ settings = (
334
+ ds if spec.profile else ds["METADATA/ALGORITHM_SETTINGS"]
335
+ )
336
+ version = (
337
+ str(settings.getncattr("product_version"))
338
+ if "product_version" in settings.ncattrs()
339
+ else None
340
+ )
341
+ time_name = f"{geo}/" + ("time" if spec.profile else "Time")
342
+ time = array(time_name)
343
+ time_units = ds[time_name].getncattr("units")
344
+ # NO2 v4 omits the epoch; it uses the GEMS mission epoch.
345
+ known_units = (
346
+ "second",
347
+ "Seconds since 2000-01-01 12 UTC",
348
+ "seconds since 2000-01-01 12:00:00 UTC",
349
+ )
350
+ if time_units not in known_units:
351
+ raise ValueError(f"未知 GEMS 时间单位: {time_units!r}")
352
+ time = time[np.isfinite(time) & (np.abs(time) < 1e12)]
353
+ meta = {
354
+ "file": str(path),
355
+ "product_version": version,
356
+ "variable": f"{data}/{spec.value}",
357
+ "source_unit": units,
358
+ "unit_scale": spec.scale,
359
+ "nominal_utc": nominal.isoformat(),
360
+ "nominal_local": nominal.astimezone(CHINA_TIME).isoformat(),
361
+ "observation_start_utc": (
362
+ (EPOCH + timedelta(seconds=float(time.min()))).isoformat()
363
+ if time.size
364
+ else None
365
+ ),
366
+ "observation_end_utc": (
367
+ (EPOCH + timedelta(seconds=float(time.max()))).isoformat()
368
+ if time.size
369
+ else None
370
+ ),
371
+ "time_epoch": EPOCH.isoformat(),
372
+ "quality": {
373
+ "flag_variable": flag_path,
374
+ "accepted_values": [0],
375
+ "solar_zenith_max_exclusive": 70,
376
+ "viewing_zenith_max_exclusive": 70,
377
+ "cloud_variable": spec.cloud,
378
+ "cloud_fraction_max": self.cloud_fraction_max,
379
+ },
380
+ "n_pixels": int(mask.sum()),
381
+ "footprint_source": footprint_source,
382
+ "footprint_geometry": "native_quadrilateral",
383
+ }
384
+ if not mask.any():
385
+ meta["empty_reason"] = "区域内无通过质量筛选的像元"
386
+ return SwathData(
387
+ lon[mask],
388
+ lat[mask],
389
+ value[mask] * spec.scale,
390
+ int(mask.sum()),
391
+ 1,
392
+ 0,
393
+ meta,
394
+ corner_lon[mask],
395
+ corner_lat[mask],
396
+ )
397
+
398
+
399
+ class GemsNO2Reader(GemsReader):
400
+ spec = GemsSpec("NO2", "ColumnAmountNO2Trop", "Data Fields/CloudFraction")
401
+
402
+
403
+ class GemsHCHOReader(GemsReader):
404
+ spec = GemsSpec(
405
+ "HCHO", "ColumnAmount", "Data Fields/CloudRadianceFraction"
406
+ )
407
+
408
+
409
+ class GemsO3Reader(GemsReader):
410
+ spec = GemsSpec(
411
+ "O3P",
412
+ "total_ozone_column",
413
+ "support_data/effective_cloud_fraction",
414
+ scale=1.0,
415
+ profile=True,
416
+ )
417
+
418
+
419
+ class GemsO3PRReader(GemsReader):
420
+ spec = GemsSpec(
421
+ "O3P",
422
+ "troposphere_ozone_column",
423
+ "support_data/effective_cloud_fraction",
424
+ scale=1.0,
425
+ profile=True,
426
+ )
rsplot/recent.py CHANGED
@@ -20,7 +20,11 @@ from rich.progress import track
20
20
 
21
21
  from rsplot.config import get_exceedance_threshold
22
22
  from rsplot.geo.gridding import grid_coverage_in_region
23
- from rsplot.geo.processing import buffered_extent, prepare_swath_grid
23
+ from rsplot.geo.processing import (
24
+ buffered_extent,
25
+ prepare_existing_grid,
26
+ prepare_swath_grid,
27
+ )
24
28
  from rsplot.readers import ProductInfo
25
29
  from rsplot.readers.guokong import (
26
30
  mask_stations_to_region,
@@ -87,16 +91,47 @@ def summarize_recent_raster(
87
91
  for d in track(
88
92
  dates, description=f"[cyan]统计 {product.upper()} 最近数据..."
89
93
  ):
94
+ meta: dict[str, Any] = {}
90
95
  try:
91
- swath = reader.read(data_dir, d, read_extent, qa_threshold)
92
- prepared = prepare_swath_grid(
93
- swath.lon,
94
- swath.lat,
95
- swath.values,
96
- read_extent,
97
- res,
98
- region.geometry,
99
- )
96
+ if prod_info.sensor == "gems":
97
+ from rsplot.readers.gems import GEMS_ANALYSIS_RESOLUTION
98
+ from rsplot.temporal import read_scan_mean
99
+
100
+ batch = read_scan_mean(
101
+ reader=reader,
102
+ data_dir=data_dir,
103
+ request=d,
104
+ extent=read_extent,
105
+ res=GEMS_ANALYSIS_RESOLUTION,
106
+ )
107
+ prepared = prepare_existing_grid(
108
+ batch.LON,
109
+ batch.LAT,
110
+ batch.grid,
111
+ region.geometry,
112
+ fill_gaps=False,
113
+ )
114
+ meta = {
115
+ "n_pixels": batch.n_pixels,
116
+ "n_files": batch.n_files,
117
+ "n_broken": batch.n_broken,
118
+ **batch.metadata,
119
+ }
120
+ else:
121
+ swath = reader.read(data_dir, d, read_extent, qa_threshold)
122
+ prepared = prepare_swath_grid(
123
+ swath.lon,
124
+ swath.lat,
125
+ swath.values,
126
+ read_extent,
127
+ res,
128
+ region.geometry,
129
+ )
130
+ meta = {
131
+ "n_pixels": int(swath.n_pixels),
132
+ "n_files": int(swath.n_files),
133
+ "n_broken": int(swath.n_broken),
134
+ }
100
135
  stats = _grid_stats(
101
136
  prepared.lon,
102
137
  prepared.lat,
@@ -111,11 +146,7 @@ def summarize_recent_raster(
111
146
  ok=True,
112
147
  value=stats.get("mean"),
113
148
  stats=stats,
114
- meta={
115
- "n_pixels": int(swath.n_pixels),
116
- "n_files": int(swath.n_files),
117
- "n_broken": int(swath.n_broken),
118
- },
149
+ meta=meta,
119
150
  )
120
151
  )
121
152
  except (FileNotFoundError, ValueError) as e:
@@ -125,7 +156,7 @@ def summarize_recent_raster(
125
156
  ok=False,
126
157
  value=None,
127
158
  stats={},
128
- meta={},
159
+ meta=meta,
129
160
  error=str(e).splitlines()[0],
130
161
  )
131
162
  )
@@ -207,6 +238,8 @@ def build_recent_result(
207
238
  product: str | None = None,
208
239
  variable: str | None = None,
209
240
  unit: str | None = None,
241
+ sensor: str | None = None,
242
+ quantity: str | None = None,
210
243
  ) -> dict[str, Any]:
211
244
  """Build the JSON payload for a recent run."""
212
245
  daily_rows = [_daily_to_json(row) for row in daily]
@@ -240,6 +273,16 @@ def build_recent_result(
240
273
  "trend": _trend_summary(daily),
241
274
  "warnings": _warnings(daily),
242
275
  }
276
+ if source == "raster" and sensor == "gems":
277
+ result["sensor"] = sensor
278
+ result["quantity"] = quantity
279
+ result["statistics_basis"] = "unfilled_unsmoothed_grid"
280
+ result["input_timezone"] = "Asia/Shanghai"
281
+ result["params"] = {
282
+ key: value
283
+ for key, value in params.items()
284
+ if key != "qa_threshold"
285
+ }
243
286
  if product is not None:
244
287
  result["product"] = product
245
288
  if variable is not None:
@@ -25,6 +25,58 @@ def serialize_region(region: RegionInfo) -> dict[str, Any]:
25
25
  result["administrative_level"] = region.administrative_level
26
26
  if region.subdivision_level is not None:
27
27
  result["subdivision_level"] = region.subdivision_level
28
+ if region.members:
29
+ result["members"] = [
30
+ serialize_member(member) for member in region.members
31
+ ]
32
+ return result
33
+
34
+
35
+ def serialize_member(member: RegionInfo) -> dict[str, Any]:
36
+ """Identify a selected member with its canonical, qualified name."""
37
+ result = serialize_region(member)
38
+ result["name"] = member.qualified_name or member.name
39
+ result.setdefault(
40
+ "administrative_level", member.administrative_level or member.level
41
+ )
42
+ return result
43
+
44
+
45
+ def member_basic_stats(values: np.ndarray) -> dict[str, Any]:
46
+ """Explicit null summaries retain members with no valid observations."""
47
+ result = basic_stats(values)
48
+ if result:
49
+ return result
50
+ return {
51
+ "n": 0,
52
+ **dict.fromkeys(
53
+ ("min", "max", "mean", "median", "p10", "p25", "p75", "p90")
54
+ ),
55
+ }
56
+
57
+
58
+ def grid_member_stats(
59
+ lon: np.ndarray,
60
+ lat: np.ndarray,
61
+ grid: np.ndarray,
62
+ region: RegionInfo,
63
+ ) -> list[dict[str, Any]]:
64
+ """Select each member on the same statistical grid, never sum overlaps."""
65
+ from rsplot.geo.gridding import mask_to_region
66
+
67
+ result = []
68
+ for member in region.members:
69
+ selected = mask_to_region(lon, lat, grid, member.geometry)
70
+ stats = grid_stats(
71
+ lon,
72
+ lat,
73
+ selected,
74
+ member,
75
+ valid_key="valid_region_pixels",
76
+ total_key="total_region_pixels",
77
+ )
78
+ stats.update(member_basic_stats(selected))
79
+ result.append({"region": serialize_member(member), "stats": stats})
28
80
  return result
29
81
 
30
82
 
@@ -136,3 +188,32 @@ def grid_stats(
136
188
  total_key: total,
137
189
  **basic_stats(grid),
138
190
  }
191
+
192
+
193
+ def grid_boundary_policy(
194
+ lon: np.ndarray,
195
+ lat: np.ndarray,
196
+ display_grid: np.ndarray,
197
+ region: RegionInfo,
198
+ ) -> dict[str, Any]:
199
+ """Describe distinct display and statistics spatial selection rules."""
200
+ from rsplot.geo.gridding import region_grid_mask
201
+ from rsplot.geo.processing import region_cell_mask
202
+
203
+ visible = np.isfinite(display_grid) & region_cell_mask(
204
+ lon, lat, region.geometry
205
+ )
206
+ inside = region_grid_mask(lon, lat, region.geometry)
207
+ return {
208
+ "statistics": {
209
+ "selection": "grid_center_strictly_inside",
210
+ "weighting": "equal_cells",
211
+ "coverage": "valid_center_count / total_center_count",
212
+ },
213
+ "display": {
214
+ "selection": "valid_cells_intersecting_region",
215
+ "clip": "administrative_geometry",
216
+ "n_valid_intersecting_cells": int(visible.sum()),
217
+ "n_cells_with_centers_outside": int((visible & ~inside).sum()),
218
+ },
219
+ }
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
7
7
  import numpy as np
8
8
 
9
9
  from rsplot.result_builders.common import (
10
+ grid_boundary_policy,
10
11
  grid_per_admin_stats,
11
12
  serialize_region,
12
13
  top_hotspots,
@@ -33,6 +34,7 @@ def build_forecast_result(
33
34
  vmin: float,
34
35
  vmax: float,
35
36
  output: str,
37
+ display_grid: np.ndarray | None = None,
36
38
  station_data: StationData | None = None,
37
39
  station_datetime: str | None = None,
38
40
  station_threshold: float | None = None,
@@ -100,6 +102,11 @@ def build_forecast_result(
100
102
  station_block.pop(key, None)
101
103
  result["station"] = station_block
102
104
 
105
+ if display_grid is not None:
106
+ result["boundary_policy"] = grid_boundary_policy(
107
+ LON, LAT, display_grid, region
108
+ )
109
+
103
110
  sub_gdf = region.sub_boundary_gdf
104
111
  if sub_gdf is not None and len(sub_gdf) > 0:
105
112
  result["spatial_summary"] = grid_per_admin_stats(