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.
@@ -6,7 +6,11 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  import numpy as np
8
8
 
9
- from rsplot.result_builders.common import serialize_region
9
+ from rsplot.result_builders.common import (
10
+ member_basic_stats,
11
+ serialize_member,
12
+ serialize_region,
13
+ )
10
14
  from rsplot.result_builders.raster import build_raster_result
11
15
  from rsplot.result_builders.station import build_station_result
12
16
 
@@ -36,6 +40,10 @@ def build_overlay_result(
36
40
  unit: str,
37
41
  cities_gdf: gpd.GeoDataFrame | None,
38
42
  output: str,
43
+ display_grid: np.ndarray | None = None,
44
+ display_lon: np.ndarray | None = None,
45
+ display_lat: np.ndarray | None = None,
46
+ time_matching: dict[str, Any] | None = None,
39
47
  ) -> dict[str, Any]:
40
48
  """Combine raster and station result blocks without shared duplication."""
41
49
  raster_block = build_raster_result(
@@ -47,6 +55,9 @@ def build_overlay_result(
47
55
  LAT=LAT,
48
56
  grid=grid,
49
57
  swath_meta=swath_meta,
58
+ display_grid=display_grid,
59
+ display_lon=display_lon,
60
+ display_lat=display_lat,
50
61
  vmin=raster_vmin,
51
62
  vmax=raster_vmax,
52
63
  output=output,
@@ -60,11 +71,25 @@ def build_overlay_result(
60
71
  cities_gdf=cities_gdf,
61
72
  output=output,
62
73
  )
74
+ if region.members:
75
+ from rsplot.readers.guokong import mask_stations_to_region
76
+
77
+ station_block["member_stats"] = []
78
+ for member in region.members:
79
+ selected = mask_stations_to_region(data, member.geometry)
80
+ station_block["member_stats"].append(
81
+ {
82
+ "region": serialize_member(member),
83
+ "n_stations": int(selected.n_stations),
84
+ "n_valid": int(selected.n_valid),
85
+ "stats": member_basic_stats(selected.values),
86
+ }
87
+ )
63
88
  for key in ("command", "image", "region"):
64
89
  raster_block.pop(key, None)
65
90
  station_block.pop(key, None)
66
91
 
67
- return {
92
+ result = {
68
93
  "command": "overlay",
69
94
  "image": output,
70
95
  "region": serialize_region(region),
@@ -73,3 +98,8 @@ def build_overlay_result(
73
98
  "raster": raster_block,
74
99
  "station": station_block,
75
100
  }
101
+ if prod_info.sensor == "gems":
102
+ result["sensor"] = "gems"
103
+ if time_matching is not None:
104
+ result["time_matching"] = time_matching
105
+ return result
@@ -7,6 +7,8 @@ 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,
11
+ grid_member_stats,
10
12
  grid_per_admin_stats,
11
13
  grid_stats,
12
14
  serialize_region,
@@ -32,6 +34,9 @@ def build_raster_result(
32
34
  vmin: float,
33
35
  vmax: float,
34
36
  output: str,
37
+ display_grid: np.ndarray | None = None,
38
+ display_lon: np.ndarray | None = None,
39
+ display_lat: np.ndarray | None = None,
35
40
  window: dict[str, Any] | None = None,
36
41
  ) -> dict[str, Any]:
37
42
  """Build the stable JSON sidecar payload for a raster run."""
@@ -56,9 +61,36 @@ def build_raster_result(
56
61
  total_key="total_region_pixels",
57
62
  ),
58
63
  }
64
+ if prod_info.sensor == "gems":
65
+ result["sensor"] = "gems"
66
+ result["quantity"] = prod_info.quantity
67
+ result["statistics_basis"] = "unfilled_unsmoothed_grid"
68
+ result["input_timezone"] = "Asia/Shanghai"
59
69
  if window is not None:
60
70
  result["window"] = window
61
71
 
72
+ if display_grid is not None:
73
+ result["boundary_policy"] = grid_boundary_policy(
74
+ LON if display_lon is None else display_lon,
75
+ LAT if display_lat is None else display_lat,
76
+ display_grid,
77
+ region,
78
+ )
79
+
80
+ if (
81
+ display_grid is not None
82
+ and swath_meta.get("display_support") == "fixed_analysis_valid_cells"
83
+ ):
84
+ result["boundary_policy"]["display"]["clip"] = (
85
+ "administrative_geometry_and_fixed_analysis_valid_cells"
86
+ )
87
+ result["boundary_policy"]["display"]["grid_shape"] = list(
88
+ display_grid.shape
89
+ )
90
+
91
+ if region.members:
92
+ result["member_stats"] = grid_member_stats(LON, LAT, grid, region)
93
+
62
94
  sub_gdf = region.sub_boundary_gdf
63
95
  if sub_gdf is not None and len(sub_gdf) > 0:
64
96
  result["spatial_summary"] = grid_per_admin_stats(
@@ -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
  grid_stats,
12
13
  serialize_region,
@@ -29,6 +30,7 @@ def build_reconstruct_result(
29
30
  vmin: float,
30
31
  vmax: float,
31
32
  output: str,
33
+ display_grid: np.ndarray | None = None,
32
34
  ) -> dict[str, Any]:
33
35
  """Build the stable JSON sidecar payload for a reconstructed grid."""
34
36
  stats = grid_stats(
@@ -72,6 +74,11 @@ def build_reconstruct_result(
72
74
  "stats": stats,
73
75
  }
74
76
 
77
+ if display_grid is not None:
78
+ result["boundary_policy"] = grid_boundary_policy(
79
+ LON, LAT, display_grid, region
80
+ )
81
+
75
82
  sub_gdf = region.sub_boundary_gdf
76
83
  if sub_gdf is not None and len(sub_gdf) > 0:
77
84
  result["spatial_summary"] = grid_per_admin_stats(
rsplot/temporal.py CHANGED
@@ -7,9 +7,9 @@ remain available for compatibility.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from dataclasses import dataclass
10
+ from dataclasses import dataclass, field
11
11
  from datetime import datetime, timedelta
12
- from typing import TYPE_CHECKING
12
+ from typing import TYPE_CHECKING, Any
13
13
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
14
14
 
15
15
  import numpy as np
@@ -135,6 +135,7 @@ class WindowResult:
135
135
  n_pixels_total: int
136
136
  n_files_total: int
137
137
  n_broken_total: int
138
+ daily_metadata: dict[str, Any] = field(default_factory=dict)
138
139
 
139
140
 
140
141
  def compute_window_mean(
@@ -156,6 +157,7 @@ def compute_window_mean(
156
157
  valid_counts: np.ndarray | None = None
157
158
  dates_found: list[str] = []
158
159
  dates_failed: dict[str, str] = {}
160
+ daily_metadata: dict[str, Any] = {}
159
161
  n_pix_total = 0
160
162
  n_files_total = 0
161
163
  n_broken_total = 0
@@ -164,19 +166,22 @@ def compute_window_mean(
164
166
 
165
167
  for date in track(dates, description=f"[cyan]读取 {label} 窗口..."):
166
168
  try:
167
- swath = reader.read(
168
- data_dir,
169
- date,
170
- extent,
169
+ batch = read_scan_mean(
170
+ reader=reader,
171
+ data_dir=data_dir,
172
+ request=date,
173
+ extent=extent,
174
+ res=res,
171
175
  qa_threshold=qa_threshold,
172
176
  )
173
- day_lon, day_lat, grid = grid_data(
174
- swath.lon,
175
- swath.lat,
176
- swath.values,
177
- extent,
178
- res,
179
- )
177
+ if batch.metadata:
178
+ daily_metadata[date] = batch.metadata
179
+ n_pix_total += batch.n_pixels
180
+ n_files_total += batch.n_files
181
+ n_broken_total += batch.n_broken
182
+ day_lon, day_lat, grid = batch.LON, batch.LAT, batch.grid
183
+ if not np.isfinite(grid).any():
184
+ raise ValueError(batch.failure_message())
180
185
  LON, LAT = day_lon, day_lat
181
186
  if sum_grid is None:
182
187
  sum_grid = np.zeros(grid.shape, dtype=float)
@@ -185,9 +190,6 @@ def compute_window_mean(
185
190
  np.add(sum_grid, grid, out=sum_grid, where=finite)
186
191
  valid_counts += finite
187
192
  dates_found.append(date)
188
- n_pix_total += int(swath.n_pixels)
189
- n_files_total += int(swath.n_files)
190
- n_broken_total += int(swath.n_broken)
191
193
  except (FileNotFoundError, ValueError) as e:
192
194
  dates_failed[date] = str(e).splitlines()[0]
193
195
 
@@ -221,4 +223,125 @@ def compute_window_mean(
221
223
  n_pixels_total=n_pix_total,
222
224
  n_files_total=n_files_total,
223
225
  n_broken_total=n_broken_total,
226
+ daily_metadata=daily_metadata,
227
+ )
228
+
229
+
230
+ @dataclass
231
+ class ScanMean:
232
+ """An unfilled grid mean with per-cell temporal sample counts."""
233
+
234
+ LON: np.ndarray
235
+ LAT: np.ndarray
236
+ grid: np.ndarray
237
+ n_valid: np.ndarray
238
+ n_pixels: int
239
+ n_files: int
240
+ n_broken: int
241
+ metadata: dict[str, Any]
242
+
243
+ def failure_message(self) -> str:
244
+ """Keep CLI errors compact; full per-file diagnostics stay in JSON."""
245
+ message = (
246
+ f"{self.metadata.get('request', '')} 区域内无有效扫描 "
247
+ f"({self.n_files} 文件, {self.n_broken} 损坏)。"
248
+ )
249
+ examples = list(self.metadata.get("failed_files", {}).items())[:3]
250
+ if examples:
251
+ from pathlib import Path
252
+
253
+ message += " " + "; ".join(
254
+ f"{Path(path).name}: {error}" for path, error in examples
255
+ )
256
+ return message
257
+
258
+
259
+ def read_scan_mean(
260
+ *,
261
+ reader: BaseReader,
262
+ data_dir: str,
263
+ request: str,
264
+ extent: tuple[float, float, float, float],
265
+ res: float,
266
+ qa_threshold: float = 0.5,
267
+ ) -> ScanMean:
268
+ """Grid each scan independently, then average valid scans equally."""
269
+ # A NaN sentinel establishes the grid even if every scan is invalid.
270
+ lon, lat, template = grid_data(
271
+ np.array([extent[0]]),
272
+ np.array([extent[2]]),
273
+ np.array([np.nan]),
274
+ extent,
275
+ res,
276
+ )
277
+ footprints = getattr(reader, "uses_footprints", False)
278
+ if footprints:
279
+ from rsplot.geo.footprints import overlap_mean, regular_grid
280
+
281
+ lon, lat = regular_grid(extent, res)
282
+ template = np.full(lon.shape, np.nan)
283
+ total = np.zeros(template.shape, dtype=float)
284
+ counts = np.zeros(template.shape, dtype=np.int32)
285
+ n_pixels = n_files = n_broken = 0
286
+ scans = []
287
+ iterator = getattr(reader, "iter_scans", None)
288
+ scans_iter = (
289
+ iterator(data_dir, request, extent, qa_threshold)
290
+ if iterator is not None
291
+ else (reader.read(data_dir, request, extent, qa_threshold),)
292
+ )
293
+ for scan in scans_iter:
294
+ n_pixels += scan.n_pixels
295
+ n_files += scan.n_files
296
+ n_broken += scan.n_broken
297
+ if scan.metadata:
298
+ scans.append(scan.metadata)
299
+ if not scan.n_pixels:
300
+ continue
301
+ if footprints:
302
+ if scan.corner_lon is None or scan.corner_lat is None:
303
+ raise ValueError("GEMS 扫描缺少像元边界,不能退回中心点分箱。")
304
+ grid = overlap_mean(
305
+ scan.corner_lon, scan.corner_lat, scan.values, lon, lat, res
306
+ )
307
+ else:
308
+ _, _, grid = grid_data(
309
+ scan.lon, scan.lat, scan.values, extent, res
310
+ )
311
+ valid = np.isfinite(grid)
312
+ np.add(total, grid, out=total, where=valid)
313
+ counts += valid
314
+ mean = np.full(template.shape, np.nan)
315
+ np.divide(total, counts, out=mean, where=counts > 0)
316
+ covered = counts[counts > 0]
317
+ metadata = {
318
+ "request": request,
319
+ "input_timezone": "Asia/Shanghai",
320
+ "aggregation": "scan" if len(request) == 12 else "equal_scan_mean",
321
+ "scans": scans,
322
+ "n_scans_found": len(scans),
323
+ "n_scans_valid": sum(s.get("n_pixels", 0) > 0 for s in scans),
324
+ "failed_files": {s["file"]: s["error"] for s in scans if "error" in s},
325
+ "coverage_scope": "read_extent",
326
+ "read_extent": list(extent),
327
+ "coverage_scans": {
328
+ "min": int(covered.min()) if covered.size else 0,
329
+ "max": int(covered.max()) if covered.size else 0,
330
+ "mean": float(covered.mean()) if covered.size else 0.0,
331
+ },
332
+ }
333
+ if footprints:
334
+ metadata["spatial_aggregation"] = "footprint_overlap_area_mean"
335
+ metadata["area_crs"] = "EPSG:6933"
336
+ metadata["analysis_resolution_deg"] = res
337
+ metadata["grid_alignment"] = "global_multiples_of_resolution"
338
+ return ScanMean(
339
+ lon,
340
+ lat,
341
+ mean,
342
+ counts,
343
+ n_pixels,
344
+ n_files,
345
+ n_broken,
346
+ metadata if scans else {},
224
347
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rsplot
3
- Version: 0.3.1.dev3
3
+ Version: 0.3.2.dev4
4
4
  Summary: CLI tool for plotting Remote Sensing data, designed for Agent.
5
5
  Project-URL: Repository, https://git.lug.ustc.edu.cn/yaoyhu/rsplot
6
6
  Project-URL: GitHub, https://github.com/yaoyhu/rsplot
@@ -217,6 +217,7 @@ Requires-Dist: geopandas>=0.13
217
217
  Requires-Dist: matplotlib>=3.7
218
218
  Requires-Dist: netcdf4>=1.7.3
219
219
  Requires-Dist: numpy>=1.24
220
+ Requires-Dist: pyproj>=3.1
220
221
  Requires-Dist: rich>=13.0.0
221
222
  Requires-Dist: scipy>=1.10
222
223
  Requires-Dist: shapely>=2.0
@@ -436,6 +437,219 @@ writes a JSON sidecar with the same base name. Reconstructed-grid JSON records
436
437
  both the China-time input and matched UTC archive hour, source file, native and
437
438
  output resolution, regional statistics, administrative summaries, and hotspots.
438
439
 
440
+ ## Select multiple regions without configuration
441
+
442
+ `raster` and `overlay` accept a quoted, comma-separated region argument.
443
+ Use English or Chinese commas; surrounding whitespace and embedded line
444
+ breaks are ignored in a list. Province, city, and qualified county/township
445
+ names can be mixed. County and township names still require their city,
446
+ e.g. `中山市/东区街道`. Other commands retain their single-region input.
447
+
448
+ ```bash
449
+ # One map covering three selected townships.
450
+ uv run rsplot raster \
451
+ "中山市/东区街道,中山市/南区街道,中山市/石岐街道" 20260907 \
452
+ --sensor gems -p o3pr --cf 1 --res 0.075 --smooth 0 \
453
+ --basemap none -o ./logs/zhongshan/three_streets_raster.png
454
+
455
+ # The same daily satellite field with observations at 19:00 China time.
456
+ uv run rsplot overlay \
457
+ "中山市/东区街道,中山市/南区街道,中山市/石岐街道" 20260907 \
458
+ --sensor gems -p o3pr --station-datetime 2026090719 \
459
+ --cf 1 --res 0.075 --smooth 0 --basemap none \
460
+ -o ./logs/zhongshan/three_streets_overlay.png
461
+ ```
462
+
463
+ These examples use your existing product/station data paths. Optional
464
+ `--data-dir` for raster or `--raster-dir` for overlay overrides the satellite
465
+ root; no named region group needs to be added to configuration.
466
+
467
+ Every member is resolved before reading data. Repeated aliases are removed
468
+ in first-occurrence order; empty items and unknown names fail with an explicit
469
+ error instead of silently omitting regions. The map reads and processes the
470
+ combined extent once, but colors only the union of the selected geometries,
471
+ preserving holes and gaps between disconnected regions. Each member retains
472
+ its boundary and label. Defaults use the coarsest selected level; combined
473
+ maps use automatic geographic ticks. Explicit plot options still take
474
+ precedence. Far-apart selections share one map with intervening whitespace.
475
+
476
+ Combined maps use `联合区域(N个)` as their default name. The JSON adds
477
+ `region.members` with canonical qualified names, administrative levels and
478
+ extents. Existing overall statistics operate on the geometric union, without
479
+ double-counting parent/child overlaps. `member_stats` summarizes each selected
480
+ member using that same statistical grid; for overlay these lists are under
481
+ `raster.member_stats` and `station.member_stats`. A member with no valid data
482
+ remains in the list with zero valid counts and null numeric summaries.
483
+ Members may overlap, so their counts must not be added to recover the union
484
+ count. Grid centers exactly on a member boundary are excluded from that
485
+ member's statistics, even if they lie inside the combined region. Station
486
+ selection retains the existing boundary-tolerance rule. `spatial_summary`
487
+ continues to describe subdivisions, independently of selected-member stats.
488
+ Single-region inputs retain their existing output contract.
489
+
490
+ Run the isolated multi-region regressions:
491
+
492
+ ```bash
493
+ uv run python -m unittest discover -s tests -p 'test_multi_regions.py' -v
494
+ ```
495
+
496
+ ## Raster boundaries and regional statistics
497
+
498
+ `raster`, `overlay`, `forecast` (including station overlays), and
499
+ `reconstruct` prepare separate display and statistics grids:
500
+
501
+ - **Display:** retain valid grid cells that intersect the administrative
502
+ geometry, even when their centers are outside. Clip the colored layer to
503
+ the actual boundary, including holes and disconnected islands. Display
504
+ smoothing uses surrounding values but preserves the input valid-cell mask.
505
+ - **Statistics:** select grid centers **strictly inside** the geometry;
506
+ centers exactly on the boundary are excluded. Each valid selected cell has
507
+ equal weight. Coverage is the valid selected-cell count divided by the
508
+ total selected-cell count, not the percentage of colored land area.
509
+ Existing product-specific gap-filling and statistical smoothing behavior
510
+ remains unchanged; GEMS statistics remain unfilled and unsmoothed.
511
+
512
+ The PNG sidecar adds `boundary_policy` with the selection and weighting
513
+ rules, the displayed intersecting-cell count, and the count whose centers
514
+ are outside. For `overlay`, this block is nested under `raster`.
515
+ A small region can display overlapping cells while having no interior grid
516
+ centers: its statistics then report zero valid cells and null summaries.
517
+ This change does not increase the satellite's native resolution or fill
518
+ missing observations.
519
+
520
+ Run boundary-selection and actual rendering regressions (including holes
521
+ and both PlateCarree and Mercator projections):
522
+
523
+ ```bash
524
+ uv run python -m unittest discover -s tests -p 'test_boundary_display.py' -v
525
+ ```
526
+
527
+ ## GEMS satellite products
528
+
529
+ Use `--sensor gems` with `raster`, `recent`, and `overlay`. Omitting
530
+ `--sensor` retains TROPOMI behavior. GEMS FNR and satellite trend curves are
531
+ not included in this release.
532
+
533
+ Configure the three archive roots in `[paths.data_dirs]`:
534
+
535
+ ```toml
536
+ gems_no2 = "/path/to/GEMS/NO2"
537
+ gems_hcho = "/path/to/GEMS/HCHO"
538
+ gems_o3 = "/path/to/GEMS/O3"
539
+ ```
540
+
541
+ Alternatively set `RSPLOT_DATA_DIR_GEMS_NO2`, `RSPLOT_DATA_DIR_GEMS_HCHO`,
542
+ and `RSPLOT_DATA_DIR_GEMS_O3`. Each root contains `YYYYMM/DD/*.nc`.
543
+ `--data-dir` (`--raster-dir` for overlay) overrides the configured root.
544
+
545
+ | Product | GEMS variable | Display unit |
546
+ | --- | --- | --- |
547
+ | `no2` | `Data Fields/ColumnAmountNO2Trop` | 10¹⁵ molecules/cm² |
548
+ | `hcho` | `Data Fields/ColumnAmount` | 10¹⁵ molecules/cm² |
549
+ | `o3` | `product/total_ozone_column` | DU |
550
+ | `o3pr` | `product/troposphere_ozone_column` | DU |
551
+
552
+ Both ozone quantities use the O3P files in `gems_o3`; `o3pr` reads the
553
+ provided tropospheric column without reintegrating the profile. Neither
554
+ quantity represents surface ozone concentration.
555
+
556
+ GEMS dates and scan requests use **Asia/Shanghai**. `YYYYMMDDHHMM` selects
557
+ an exact nominal scan; `YYYYMMDD` computes the day's valid-scan mean.
558
+ For example, `202609010845` selects the `20260901_0045` UTC archive scan.
559
+ The filename is the nominal scan time, not a simultaneous observation time
560
+ for every pixel: GEMS scans begin at :45 UTC and take about 30 minutes
561
+ ([NESC operation schedule](https://nesc.nier.go.kr/en/html/satellite/operation.do)).
562
+ Missing scan requests report available local times instead of selecting a
563
+ replacement. Duplicate files for one nominal scan are rejected; use a root
564
+ containing one archive version.
565
+
566
+ ```bash
567
+ # Exact scan, daily mean, and multi-day mean.
568
+ rsplot raster 安徽 202609011145 --sensor gems -p no2 \
569
+ --basemap none --output /tmp/gems_no2_scan.png
570
+ rsplot raster 安徽 20260901 --sensor gems -p hcho \
571
+ --basemap none --output /tmp/gems_hcho_daily.png
572
+ rsplot raster 安徽 20260901-20260902 --sensor gems -p o3pr \
573
+ --basemap none --output /tmp/gems_o3pr_window.png
574
+
575
+ # Recent daily regional statistics (JSON only).
576
+ rsplot recent 安徽 20260902 --days 2 --source raster --sensor gems -p no2 \
577
+ --output /tmp/gems_no2_recent.json
578
+
579
+ # 11:45 China-time scan with observations at the nearest hour, 12:00.
580
+ rsplot overlay 安徽 202609011145 --sensor gems -p no2 \
581
+ --basemap none --output /tmp/gems_no2_overlay.png
582
+ # Use --station-datetime YYYYMMDDHH for an intentional time override.
583
+ ```
584
+
585
+ Each scan is first remapped by pixel/target overlap area in the equal-area
586
+ EPSG:6933 projection, onto a **globally aligned 0.05° analysis grid**. This is
587
+ a numerical analysis spacing, not native satellite resolution. Daily means
588
+ weight valid scans equally at each grid cell; multi-day means then weight
589
+ valid days equally. A daily cell needs at least one valid scan. Window `--n-min`
590
+ continues to count **days**, using the existing product defaults, and is not
591
+ available for a single scan. These are means of available daytime scans,
592
+ not full 24-hour means. Empty scans and failed files do not contribute zeros.
593
+
594
+ For GEMS `raster` and `overlay`, **`--res` controls only the display grid**.
595
+ Display cells receive area-weighted analysis values and are clipped to the
596
+ fixed analysis grid's valid-cell union and the administrative boundary.
597
+ Changing `--res` can change displayed averages, but cannot move the valid
598
+ support or change the scientific statistics. `--smooth` is applied on the
599
+ fixed analysis grid for display only, preserving its valid mask. GEMS
600
+ `recent` uses the same 0.05° analysis spacing and ignores `--res`.
601
+ TROPOMI keeps its existing resolution behavior.
602
+
603
+ When available, GEMS pixel boundaries use file `CornerLongitude` and
604
+ `CornerLatitude` arrays. The checked O3P and NO2 files have centers only;
605
+ their quadrilateral boundaries are **estimates from adjacent unfiltered
606
+ native centers**, with linear extrapolation at the outer scan edge. Missing
607
+ geolocation is not interpolated, and rejected retrievals do not contribute
608
+ values. This is not a recovery of official footprints or extra observations.
609
+ Analysis cells need positive-area overlap with valid footprints; their
610
+ coverage percentage counts valid analysis centers, not precisely observed
611
+ land area. Subcell gaps remain below the 0.05° analysis spacing.
612
+
613
+ Sidecars record `footprint_source` per scan, `spatial_aggregation`,
614
+ `area_crs`, `analysis_resolution_deg`, `display_resolution_deg`, and
615
+ `display_support`. The compatibility `swath.resolution_deg` describes the
616
+ analysis grid. Statistics differ from the previous center-point-binning
617
+ implementation, but remain identical across display resolutions.
618
+
619
+ The initial GEMS filter retains final quality flag **0**, finite coordinates
620
+ and columns, SZA/VZA below 70°, and a nonnegative cloud measure no greater
621
+ than `--cf` (default 0.5). The cloud measures differ: NO2 uses `CloudFraction`,
622
+ HCHO uses `CloudRadianceFraction`, and O3P uses `effective_cloud_fraction`.
623
+ `--qa` applies only to TROPOMI and is rejected for GEMS. Quality-filtered
624
+ negative columns are retained; HCHO gap-filled flag classes are excluded.
625
+ This explicit filter is recorded with each file's version and variable in
626
+ JSON; quality flag meanings must not be transferred between products.
627
+
628
+ GEMS JSON statistics, spatial summaries, and hotspots use the **unfilled,
629
+ unsmoothed** regional grid. Smoothing affects the map only and cannot create
630
+ new valid cells. Sidecars include `sensor`, `quantity`, the requested local
631
+ time, nominal UTC/local scan times, actual observation time ranges, filter
632
+ settings, file failures, and scan-count summaries. Window metadata retains
633
+ per-day diagnostics. Existing TROPOMI fields remain compatible; the boundary
634
+ policy block described above is additive.
635
+
636
+ GEMS `overlay` accepts an exact scan or a daily mean. A daily mean requires
637
+ an explicit `--station-datetime YYYYMMDDHH`; its title and JSON distinguish
638
+ the daily satellite mean from the hourly station observation, without a
639
+ fictitious scan-time offset. Exact scans match the nearest station hour
640
+ (at most 30 minutes, ties forward). A missing station hour is an error.
641
+ `--station-datetime` records a manual override and its time difference.
642
+ NO2 pairs with NO2; O3/O3PR pair with O3; HCHO requires an explicit `--var`.
643
+ Satellite columns and station concentrations retain independent colorbars
644
+ and units; the map shows spatial juxtaposition, not a column-to-surface
645
+ conversion.
646
+
647
+ Run the self-contained GEMS regressions without adding pytest:
648
+
649
+ ```bash
650
+ uv run python -m unittest discover -s tests -p 'test_gems.py' -v
651
+ ```
652
+
439
653
  ## License
440
654
 
441
655
  This project is distributed under the license in `LICENSE`.