meteoraster 3.0__tar.gz → 3.1__tar.gz

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.
Files changed (33) hide show
  1. {meteoraster-3.0/meteoraster.egg-info → meteoraster-3.1}/PKG-INFO +19 -3
  2. {meteoraster-3.0 → meteoraster-3.1}/README.md +18 -2
  3. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/meteoraster.py +44 -5
  4. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/zonal.py +153 -14
  5. {meteoraster-3.0 → meteoraster-3.1/meteoraster.egg-info}/PKG-INFO +19 -3
  6. {meteoraster-3.0 → meteoraster-3.1}/pyproject.toml +1 -1
  7. {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_base.py +2 -1
  8. {meteoraster-3.0 → meteoraster-3.1}/tests/test_quantiles.py +12 -0
  9. {meteoraster-3.0 → meteoraster-3.1}/tests/test_zonal.py +125 -6
  10. {meteoraster-3.0 → meteoraster-3.1}/LICENSE +0 -0
  11. {meteoraster-3.0 → meteoraster-3.1}/MANIFEST.in +0 -0
  12. {meteoraster-3.0 → meteoraster-3.1}/NOTICE +0 -0
  13. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/__init__.py +0 -0
  14. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/_lazy.py +0 -0
  15. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/io.py +0 -0
  16. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/resample.py +0 -0
  17. {meteoraster-3.0 → meteoraster-3.1}/meteoraster/utils.py +0 -0
  18. {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/SOURCES.txt +0 -0
  19. {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/dependency_links.txt +0 -0
  20. {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/requires.txt +0 -0
  21. {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/top_level.txt +0 -0
  22. {meteoraster-3.0 → meteoraster-3.1}/setup.cfg +0 -0
  23. {meteoraster-3.0 → meteoraster-3.1}/tests/conftest.py +0 -0
  24. {meteoraster-3.0 → meteoraster-3.1}/tests/test_deprecations.py +0 -0
  25. {meteoraster-3.0 → meteoraster-3.1}/tests/test_io.py +0 -0
  26. {meteoraster-3.0 → meteoraster-3.1}/tests/test_kml_catchments.py +0 -0
  27. {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_extra.py +0 -0
  28. {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_plots.py +0 -0
  29. {meteoraster-3.0 → meteoraster-3.1}/tests/test_real_rasters.py +0 -0
  30. {meteoraster-3.0 → meteoraster-3.1}/tests/test_regressions.py +0 -0
  31. {meteoraster-3.0 → meteoraster-3.1}/tests/test_resample.py +0 -0
  32. {meteoraster-3.0 → meteoraster-3.1}/tests/test_resample_semantics.py +0 -0
  33. {meteoraster-3.0 → meteoraster-3.1}/tests/test_utils_readers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meteoraster
3
- Version: 3.0
3
+ Version: 3.1
4
4
  Summary: Module for handling distributed ensemble and probabilistic forecast data (5-D meteorological rasters)
5
5
  Author-email: Jose Pedro Matos <jose.matos@tecnico.ulisboa.pt>
6
6
  License-Expression: Apache-2.0
@@ -238,10 +238,19 @@ ts = mr.get_values_from_latlon(lat=35.0, lon=0.0)
238
238
 
239
239
  ```python
240
240
  # agg: DataFrame indexed by production date, columns MultiIndex(zone, leadtime, ensemble_member)
241
- # centroids: DataFrame of zone centroids (x, y)
241
+ # centroids: DataFrame of zone centroids (x, y) and grid_fraction (share of the zone on the grid)
242
242
  agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id")
243
+
244
+ # v3.0 numbers (square-degree weights) instead of true WGS84 areas
245
+ agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id",
246
+ area_weighting="planar")
243
247
  ```
244
248
 
249
+ Coordinates are WGS84 lon/lat of cell **centres** (curvilinear 2-D grids included);
250
+ KML edges are straight lines in lon/lat. Weights are true WGS84 areas by default.
251
+ A zone partly outside the grid is averaged over the part inside and warns
252
+ (`on_partial=`); a reused `coverage_info` must match the raster's grid.
253
+
245
254
  Spatial **quantiles** over the same zones, area-weighted so a cell half inside a
246
255
  zone carries half the probability mass:
247
256
 
@@ -543,6 +552,12 @@ changed for any zone touching the edge of the grid: the legacy builder gave the
543
552
  outermost row and column no weight and took its validity threshold from the first
544
553
  zone. `ensemble_member` labels are now 0-based, matching the other extractors.
545
554
 
555
+ Since **v3.1** the default weights are true WGS84 areas (`area_weighting='ellipsoidal'`)
556
+ instead of square degrees. Square degrees over-weight the poleward side of a zone by
557
+ roughly tan(lat) x lat-span: measured 1 % less rain over a Zambezi-sized basin, 0.02 %
558
+ on the Vesdre catchments, 8 % on a single cell weight for a 5 deg zone at 60 N. Pass
559
+ `area_weighting='planar'` to reproduce v3.0 exactly.
560
+
546
561
  **`meteoraster.zonal`** — needs no `MeteoRaster`; takes plain coordinate arrays.
547
562
 
548
563
  | Member | Description |
@@ -553,7 +568,8 @@ zone. `ensemble_member` labels are now 0-based, matching the other extractors.
553
568
  | `quantiles_from_coverage(coverage, values, quantiles, pool_axes=(), …)` | Area-weighted spatial quantiles per zone. |
554
569
  | `weighted_quantiles(values, weights, quantiles, method='hazen')` | The quantile primitive. Reduces exactly to `np.quantile` for equal weights. |
555
570
  | `Coverage.save/load`, `Coverage.matches`, `grid_fingerprint` | Persist a coverage to a pickle-free `.npz` and check it against a grid; the fingerprint hashes the coordinates, so a shifted grid of the same shape is a miss. |
556
- | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. |
571
+ | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. `cell_areas(..., 'ellipsoidal')` gives km². |
572
+ | `ring_area`, `geometry_area`, `authalic_latitude` | Exact WGS84 areas (km²) of lon/lat rings and ogr polygons, used by `area_weighting='ellipsoidal'`. |
557
573
 
558
574
  **`meteoraster.io`** — the portable read path.
559
575
 
@@ -185,10 +185,19 @@ ts = mr.get_values_from_latlon(lat=35.0, lon=0.0)
185
185
 
186
186
  ```python
187
187
  # agg: DataFrame indexed by production date, columns MultiIndex(zone, leadtime, ensemble_member)
188
- # centroids: DataFrame of zone centroids (x, y)
188
+ # centroids: DataFrame of zone centroids (x, y) and grid_fraction (share of the zone on the grid)
189
189
  agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id")
190
+
191
+ # v3.0 numbers (square-degree weights) instead of true WGS84 areas
192
+ agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id",
193
+ area_weighting="planar")
190
194
  ```
191
195
 
196
+ Coordinates are WGS84 lon/lat of cell **centres** (curvilinear 2-D grids included);
197
+ KML edges are straight lines in lon/lat. Weights are true WGS84 areas by default.
198
+ A zone partly outside the grid is averaged over the part inside and warns
199
+ (`on_partial=`); a reused `coverage_info` must match the raster's grid.
200
+
192
201
  Spatial **quantiles** over the same zones, area-weighted so a cell half inside a
193
202
  zone carries half the probability mass:
194
203
 
@@ -490,6 +499,12 @@ changed for any zone touching the edge of the grid: the legacy builder gave the
490
499
  outermost row and column no weight and took its validity threshold from the first
491
500
  zone. `ensemble_member` labels are now 0-based, matching the other extractors.
492
501
 
502
+ Since **v3.1** the default weights are true WGS84 areas (`area_weighting='ellipsoidal'`)
503
+ instead of square degrees. Square degrees over-weight the poleward side of a zone by
504
+ roughly tan(lat) x lat-span: measured 1 % less rain over a Zambezi-sized basin, 0.02 %
505
+ on the Vesdre catchments, 8 % on a single cell weight for a 5 deg zone at 60 N. Pass
506
+ `area_weighting='planar'` to reproduce v3.0 exactly.
507
+
493
508
  **`meteoraster.zonal`** — needs no `MeteoRaster`; takes plain coordinate arrays.
494
509
 
495
510
  | Member | Description |
@@ -500,7 +515,8 @@ zone. `ensemble_member` labels are now 0-based, matching the other extractors.
500
515
  | `quantiles_from_coverage(coverage, values, quantiles, pool_axes=(), …)` | Area-weighted spatial quantiles per zone. |
501
516
  | `weighted_quantiles(values, weights, quantiles, method='hazen')` | The quantile primitive. Reduces exactly to `np.quantile` for equal weights. |
502
517
  | `Coverage.save/load`, `Coverage.matches`, `grid_fingerprint` | Persist a coverage to a pickle-free `.npz` and check it against a grid; the fingerprint hashes the coordinates, so a shifted grid of the same shape is a miss. |
503
- | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. |
518
+ | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. `cell_areas(..., 'ellipsoidal')` gives km². |
519
+ | `ring_area`, `geometry_area`, `authalic_latitude` | Exact WGS84 areas (km²) of lon/lat rings and ogr polygons, used by `area_weighting='ellipsoidal'`. |
504
520
 
505
521
  **`meteoraster.io`** — the portable read path.
506
522
 
@@ -20,6 +20,15 @@ class MeteoRaster(object):
20
20
  '''
21
21
  Custom class to handle meteorological raster files, including ensembles and forecasts
22
22
 
23
+ v3.1:
24
+ get_values_from_KML / get_quantiles_from_KML weight by true WGS84 area
25
+ (area_weighting='ellipsoidal', new default) instead of square degrees, which
26
+ over-weighted the poleward side of a zone by ~tan(lat) * lat-span. Pass
27
+ area_weighting='planar' to reproduce v3.0 exactly. A zone partly outside
28
+ the grid now warns (on_partial=) and centroids carry its grid_fraction. A
29
+ reused coverage_info built for another grid raises instead of aggregating
30
+ the wrong cells.
31
+
23
32
  v3.0:
24
33
  BREAKING - four readers are gone. meteoraster.utils keeps only
25
34
  readERA5Land_monthly and read_ERA5Land_hourly; readERA5_monthly, read_GFS,
@@ -178,7 +187,7 @@ class MeteoRaster(object):
178
187
  Untested support for pd.DateOffset
179
188
  '''
180
189
 
181
- VERSION = '3.0'
190
+ VERSION = '3.1'
182
191
  VERBOSE = True
183
192
  ENSEMBLEMEMBERpOSITION = 1
184
193
 
@@ -287,6 +296,10 @@ class MeteoRaster(object):
287
296
  if crs is None:
288
297
  crs = ccrs.PlateCarree()
289
298
 
299
+ # Recent PROJ (seen on 9.8) rejects central_longitude=None ("+pm" with no value)
300
+ if central_longitude is None:
301
+ central_longitude = 0.0
302
+
290
303
  plt.figure(figsize=figsize)
291
304
  projection = projection_fun(central_longitude=central_longitude, **projection_kwargs)
292
305
  ax = plt.axes(projection=projection)
@@ -412,7 +425,8 @@ class MeteoRaster(object):
412
425
  return ax
413
426
 
414
427
  def get_values_from_KML(self, kml, nameField=None, coverage_info=None, getCoverageInfo=False,
415
- elementwise=False, min_coverage=None, on_error='raise'):
428
+ elementwise=False, min_coverage=None, on_error='raise',
429
+ area_weighting=None, on_partial='warn'):
416
430
  '''
417
431
  Area-weighted mean of each KML zone, per production, member and leadtime.
418
432
 
@@ -434,6 +448,13 @@ class MeteoRaster(object):
434
448
  zonal.MIN_COVERAGE.
435
449
  elementwise: accepted for backwards compatibility and ignored. It skipped the
436
450
  missing-data renormalisation, which apply_coverage now always applies.
451
+ area_weighting: 'ellipsoidal' (default, true WGS84 areas), 'cos_lat', or
452
+ 'planar' (square degrees) to reproduce v3.0 numbers. See zonal.
453
+ on_partial: 'warn' (default), 'raise' or 'ignore' for zones partly outside
454
+ the grid; centroids['grid_fraction'] holds the share inside.
455
+
456
+ A reused coverage_info must have been built for this exact grid (checked by
457
+ fingerprint for zonal.Coverage, by shape for the legacy tuple).
437
458
  '''
438
459
 
439
460
  if elementwise:
@@ -447,10 +468,13 @@ class MeteoRaster(object):
447
468
  if isinstance(coverage_info, type(None)):
448
469
  coverage = _zonal.coverage_from_kml(kml, self.latitudes, self.longitudes,
449
470
  name_field=nameField, on_error=on_error,
450
- normalize='area')
471
+ normalize='area',
472
+ area_weighting=area_weighting or _zonal.DEFAULT_AREA_WEIGHTING,
473
+ on_partial=on_partial)
451
474
  coverage_info = coverage
452
475
  else:
453
476
  coverage = self.__as_coverage(coverage_info)
477
+ self.__check_coverage(coverage)
454
478
 
455
479
  agg = self.__groupByCoverage(coverage, min_coverage)
456
480
 
@@ -464,7 +488,8 @@ class MeteoRaster(object):
464
488
  def get_quantiles_from_KML(self, kml, nameField=None, coverage_info=None, getCoverageInfo=False,
465
489
  resampling=None, quantiles=(0.01, 0.1, 0.5, 0.9, 0.99),
466
490
  pool=('space',), min_coverage=None, method='hazen',
467
- precision=None, on_error='raise'):
491
+ precision=None, on_error='raise', area_weighting=None,
492
+ on_partial='warn'):
468
493
  '''
469
494
  Area-weighted spatial quantiles over the KML zones.
470
495
 
@@ -501,6 +526,7 @@ class MeteoRaster(object):
501
526
  method: 'hazen' (default) or 'linear'. See zonal.weighted_quantiles.
502
527
  precision: accepted for backwards compatibility, ignored, and warns when
503
528
  passed - the result no longer depends on it.
529
+ area_weighting, on_partial: as in get_values_from_KML.
504
530
  '''
505
531
 
506
532
  if precision is not None:
@@ -515,10 +541,13 @@ class MeteoRaster(object):
515
541
  if isinstance(coverage_info, type(None)):
516
542
  coverage = _zonal.coverage_from_kml(kml, self.latitudes, self.longitudes,
517
543
  name_field=nameField, on_error=on_error,
518
- normalize='cell_fraction')
544
+ normalize='cell_fraction',
545
+ area_weighting=area_weighting or _zonal.DEFAULT_AREA_WEIGHTING,
546
+ on_partial=on_partial)
519
547
  coverage_info = coverage
520
548
  else:
521
549
  coverage = self.__as_coverage(coverage_info)
550
+ self.__check_coverage(coverage)
522
551
 
523
552
  agg = self.__groupByQuantile(coverage, resampling, quantiles, pool, min_coverage, method)
524
553
 
@@ -546,6 +575,16 @@ class MeteoRaster(object):
546
575
  raise TypeError('coverage_info must be a zonal.Coverage or a legacy '
547
576
  '(coverage, columns, centroids) tuple.')
548
577
 
578
+ def __check_coverage(self, coverage):
579
+ '''
580
+ Raises if a reused coverage was built for a different grid.
581
+ '''
582
+
583
+ if not coverage.matches(self.latitudes, self.longitudes):
584
+ raise ValueError('coverage_info was built for a different grid (shape %s or '
585
+ 'coordinates differ from this raster %s). Rebuild it from the KML.'
586
+ % (coverage.shape, self.latitudes.shape))
587
+
549
588
  def get_cropped(self, from_prod_date=dt.datetime(1900, 1, 1), to_prod_date=dt.datetime(2199, 12, 31),
550
589
  from_lat=-90, to_lat=90, from_lon=-180, to_lon=180,
551
590
  from_leadtime=None, to_leadtime=None):
@@ -16,9 +16,15 @@ Properties worth knowing:
16
16
  weighting the whole grid. Peak memory is one block, not block x zones.
17
17
  - Missing data is renormalised and the coverage threshold applied *per zone and per
18
18
  slice*, never taking one zone as representative of the others.
19
- - Weights are areas in square degrees (`area_weighting='planar'`). They are
20
- normalised per zone, so the cos(latitude) factor nearly cancels across a basin;
21
- for continental or high-latitude zones pass `area_weighting='cos_lat'`.
19
+ - Coordinates are WGS84 lon/lat of cell *centres*; KML edges are straight lines
20
+ in lon/lat. Overlaps are cut in that plane.
21
+ - Weights default to true WGS84 areas (`area_weighting='ellipsoidal'`, exact on
22
+ the authalic sphere). `'planar'` (square degrees, the v3.0 default) is kept as a
23
+ fall-back. Normalising per zone does *not* cancel the cos(latitude) factor:
24
+ planar weights within a zone are off by ~tan(lat) * lat_span (5 deg span at
25
+ 60 deg: ~15 %). `'cos_lat'` scales each overlap by its cell-centre cos(lat).
26
+ - A zone partly outside the grid is averaged over the part inside; the fraction
27
+ inside is `Coverage.grid_fraction` and `on_partial` warns about it.
22
28
 
23
29
  Since v3.0 MeteoRaster.get_values_from_KML and get_quantiles_from_KML both run on
24
30
  this module; the coverage builder they used before got the first and third points
@@ -43,7 +49,16 @@ from . import _lazy
43
49
  #: changes how the stored weights read.
44
50
  NORMALISATIONS = ('area', 'cell_fraction', 'none')
45
51
 
46
- AREA_WEIGHTINGS = ('planar', 'cos_lat')
52
+ AREA_WEIGHTINGS = ('ellipsoidal', 'cos_lat', 'planar')
53
+
54
+ DEFAULT_AREA_WEIGHTING = 'ellipsoidal'
55
+
56
+ #: A zone whose grid_fraction is below 1 - this is reported by on_partial.
57
+ GRID_FRACTION_TOLERANCE = 1e-3
58
+
59
+ # WGS84 semi-major axis (m) and first eccentricity.
60
+ _WGS84_A = 6378137.0
61
+ _WGS84_E = 0.0818191908426215
47
62
 
48
63
  #: Weighted-quantile plotting positions. See weighted_quantiles() for why
49
64
  #: 'hazen' is the default rather than the numpy-'linear'-compatible option.
@@ -132,9 +147,11 @@ def cell_bounds(corner_lat, corner_lon):
132
147
  return lat_min, lat_max, lon_min, lon_max
133
148
 
134
149
 
135
- def cell_areas(corner_lat, corner_lon):
150
+ def cell_areas(corner_lat, corner_lon, area_weighting='planar'):
136
151
  '''
137
- Returns each cell's area (rows, cols) by the shoelace formula on its quad.
152
+ Returns each cell's area (rows, cols) from its quad.
153
+
154
+ 'planar': square degrees (shoelace). 'ellipsoidal': km^2 on WGS84.
138
155
  '''
139
156
 
140
157
  x = np.stack((corner_lon[:-1, :-1], corner_lon[:-1, 1:],
@@ -142,6 +159,9 @@ def cell_areas(corner_lat, corner_lon):
142
159
  y = np.stack((corner_lat[:-1, :-1], corner_lat[:-1, 1:],
143
160
  corner_lat[1:, 1:], corner_lat[1:, :-1]))
144
161
 
162
+ if area_weighting == 'ellipsoidal':
163
+ return ring_area(x, y)
164
+
145
165
  total = np.zeros(x.shape[1:], dtype=float)
146
166
  for i in range(4):
147
167
  j = (i + 1) % 4
@@ -150,6 +170,71 @@ def cell_areas(corner_lat, corner_lon):
150
170
  return np.abs(total) * 0.5
151
171
 
152
172
 
173
+ def authalic_latitude(latitudes):
174
+ '''
175
+ Returns the WGS84 authalic latitude (degrees): equal-area latitude on a sphere.
176
+ '''
177
+
178
+ e = _WGS84_E
179
+ sin_phi = np.sin(np.radians(np.clip(np.asarray(latitudes, dtype=float), -90.0, 90.0)))
180
+
181
+ def q(s):
182
+ return (1 - e ** 2) * (s / (1 - (e * s) ** 2)
183
+ - np.log((1 - e * s) / (1 + e * s)) / (2 * e))
184
+
185
+ return np.degrees(np.arcsin(np.clip(q(sin_phi) / q(1.0), -1.0, 1.0)))
186
+
187
+
188
+ def _authalic_radius_km():
189
+ e = _WGS84_E
190
+ q_pole = (1 - e ** 2) * (1 / (1 - e ** 2) - np.log((1 - e) / (1 + e)) / (2 * e))
191
+
192
+ return _WGS84_A * np.sqrt(q_pole / 2) / 1000.0
193
+
194
+
195
+ def ring_area(lon, lat):
196
+ '''
197
+ Area (km^2) enclosed by rings on WGS84, edges straight in lon/lat.
198
+
199
+ lon, lat: [n_vertices, ...] degrees; the ring closes itself (a repeated
200
+ first vertex adds a zero-length edge). Exact on the authalic sphere for edges
201
+ linear in (lon, authalic lat), i.e. the integral of cos(beta) dbeta dlon.
202
+ No antimeridian handling.
203
+ '''
204
+
205
+ lam = np.radians(np.asarray(lon, dtype=float))
206
+ beta = np.radians(authalic_latitude(lat))
207
+
208
+ d_lam = np.roll(lam, -1, axis=0) - lam
209
+ beta_next = np.roll(beta, -1, axis=0)
210
+ # (cos b1 - cos b2) / (b2 - b1), stable as b2 -> b1
211
+ strip = np.sin(0.5 * (beta + beta_next)) * np.sinc((beta_next - beta) / (2 * np.pi))
212
+
213
+ return np.abs((d_lam * strip).sum(axis=0)) * _authalic_radius_km() ** 2
214
+
215
+
216
+ def geometry_area(geometry):
217
+ '''
218
+ Area (km^2) on WGS84 of an ogr polygon, multipolygon or collection (holes subtracted).
219
+ '''
220
+
221
+ name = geometry.GetGeometryName()
222
+ if name == 'POLYGON':
223
+ total = 0.0
224
+ for i in range(geometry.GetGeometryCount()):
225
+ points = np.asarray(geometry.GetGeometryRef(i).GetPoints() or [], dtype=float)
226
+ if points.shape[0] < 3:
227
+ continue
228
+ area = float(ring_area(points[:, 0], points[:, 1]))
229
+ total += area if i == 0 else -area
230
+ return total
231
+ if name in ('MULTIPOLYGON', 'GEOMETRYCOLLECTION'):
232
+ return sum(geometry_area(geometry.GetGeometryRef(i))
233
+ for i in range(geometry.GetGeometryCount()))
234
+
235
+ return 0.0
236
+
237
+
153
238
  def grid_fingerprint(latitudes, longitudes):
154
239
  '''
155
240
  Returns a sha256 hex digest of the grid geometry.
@@ -201,6 +286,8 @@ class Coverage:
201
286
  offsets : len(zones)+1 offsets into cell_index / weight
202
287
  cell_index : flat (row * cols + col) indices, concatenated over zones
203
288
  weight : matching weights, concatenated over zones
289
+ grid_fraction : per zone, share of the polygon's area that lies on the grid
290
+ (None for coverages built before v3.1)
204
291
  '''
205
292
 
206
293
  zones: list
@@ -213,6 +300,7 @@ class Coverage:
213
300
  normalize: str = 'area'
214
301
  area_weighting: str = 'planar'
215
302
  fingerprint: str = ''
303
+ grid_fraction: np.ndarray = None
216
304
 
217
305
  def __len__(self):
218
306
  return len(self.zones)
@@ -226,6 +314,10 @@ class Coverage:
226
314
  raise ValueError('offsets must have len(zones)+1 entries.')
227
315
  if self.cell_index.size != self.weight.size:
228
316
  raise ValueError('cell_index and weight must have the same length.')
317
+ if self.grid_fraction is not None:
318
+ self.grid_fraction = np.asarray(self.grid_fraction, dtype=np.float64)
319
+ if self.grid_fraction.size != len(self.zones):
320
+ raise ValueError('grid_fraction must have one entry per zone.')
229
321
 
230
322
  @property
231
323
  def shape(self):
@@ -314,8 +406,13 @@ class Coverage:
314
406
  centroids = pd.DataFrame({'x': np.full(len(self), np.nan),
315
407
  'y': np.full(len(self), np.nan)}, index=self.zones)
316
408
 
409
+ extra = {}
410
+ if self.grid_fraction is not None:
411
+ extra['grid_fraction'] = self.grid_fraction
412
+
317
413
  np.savez_compressed(
318
414
  str(file),
415
+ **extra,
319
416
  zones=np.asarray([str(z) for z in self.zones], dtype='U'),
320
417
  grid=np.asarray([self.rows, self.cols], dtype='int64'),
321
418
  offsets=self.offsets,
@@ -338,13 +435,18 @@ class Coverage:
338
435
  meta = [str(v) for v in payload['meta']]
339
436
  centroids = pd.DataFrame({'x': payload['centroid_x'],
340
437
  'y': payload['centroid_y']}, index=zones)
438
+ grid_fraction = None
439
+ if 'grid_fraction' in payload.files:
440
+ grid_fraction = payload['grid_fraction']
441
+ centroids['grid_fraction'] = grid_fraction
341
442
 
342
443
  return cls(zones=zones, rows=rows, cols=cols,
343
444
  offsets=payload['offsets'],
344
445
  cell_index=payload['cell_index'],
345
446
  weight=payload['weight'],
346
447
  centroids=centroids,
347
- normalize=meta[0], area_weighting=meta[1], fingerprint=meta[2])
448
+ normalize=meta[0], area_weighting=meta[1], fingerprint=meta[2],
449
+ grid_fraction=grid_fraction)
348
450
 
349
451
  def matches(self, latitudes, longitudes):
350
452
  '''
@@ -576,12 +678,17 @@ def _as_geometry(ogr, geometry):
576
678
 
577
679
  def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
578
680
  halo=1, buffer=None, normalize='area',
579
- area_weighting='planar', min_area=0.0,
580
- on_empty='warn'):
681
+ area_weighting=DEFAULT_AREA_WEIGHTING, min_area=0.0,
682
+ on_empty='warn', on_partial='warn'):
581
683
  '''
582
684
  Builds a Coverage by exact fractional overlap of each cell with each polygon.
583
685
 
584
- geometries: ogr.Geometry, WKB bytes or WKT strings.
686
+ geometries: ogr.Geometry, WKB bytes or WKT strings, WGS84 lon/lat.
687
+ area_weighting: 'ellipsoidal' (km^2 on WGS84, default), 'cos_lat', or
688
+ 'planar' (square degrees; reproduces v3.0).
689
+ min_area: overlaps at or below this are dropped, in the units of the
690
+ overlap before normalisation (square degrees for 'planar' and
691
+ 'cos_lat', km^2 for 'ellipsoidal').
585
692
  halo: extra rings of candidate cells. The envelope prefilter is already
586
693
  conservative, so this is only numerical insurance; it is cheap
587
694
  because non-overlapping candidates contribute zero area and are
@@ -592,6 +699,9 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
592
699
  buffer: legacy envelope padding in degrees, applied in addition to halo.
593
700
  on_empty: what to do about a zone that caught no cell ('warn', 'raise',
594
701
  'ignore'). It used to be a ValueError from min() of an empty array.
702
+ on_partial: what to do about a zone partly outside the grid ('warn', 'raise',
703
+ 'ignore'). Its mean is taken over the part inside, renormalised,
704
+ so it looks plausible; `grid_fraction` says how much is inside.
595
705
  '''
596
706
 
597
707
  if normalize not in NORMALISATIONS:
@@ -600,6 +710,8 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
600
710
  raise ValueError('area_weighting must be one of %s.' % ', '.join(AREA_WEIGHTINGS))
601
711
  if on_empty not in ('warn', 'raise', 'ignore'):
602
712
  raise ValueError("on_empty must be 'warn', 'raise' or 'ignore'.")
713
+ if on_partial not in ('warn', 'raise', 'ignore'):
714
+ raise ValueError("on_partial must be 'warn', 'raise' or 'ignore'.")
603
715
 
604
716
  ogr = _lazy.ogr()
605
717
  latitudes, longitudes = as_grid(latitudes, longitudes)
@@ -607,7 +719,10 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
607
719
 
608
720
  corner_lat, corner_lon = cell_corners(latitudes, longitudes)
609
721
  lat_min, lat_max, lon_min, lon_max = cell_bounds(corner_lat, corner_lon)
610
- areas = cell_areas(corner_lat, corner_lon) if normalize == 'cell_fraction' else None
722
+ areas = None
723
+ if normalize == 'cell_fraction':
724
+ areas = cell_areas(corner_lat, corner_lon,
725
+ 'ellipsoidal' if area_weighting == 'ellipsoidal' else 'planar')
611
726
 
612
727
  scale = None
613
728
  if area_weighting == 'cos_lat':
@@ -623,13 +738,18 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
623
738
  offsets = [0]
624
739
  all_index, all_weight = [], []
625
740
  centroid_x, centroid_y = [], []
626
- empty = []
741
+ grid_fraction = []
742
+ empty, wide = [], []
627
743
 
628
744
  pad = 0.0 if buffer is None else float(buffer)
629
745
 
630
746
  for name, raw in zip(names, geometries):
631
747
  polygon = _as_geometry(ogr, raw)
632
748
  west, east, south, north = polygon.GetEnvelope()
749
+ if east - west > 180:
750
+ wide.append(name)
751
+ polygon_area = geometry_area(polygon)
752
+ inside_area = 0.0
633
753
 
634
754
  # Conservative: a cell can only intersect the polygon if its bounding box
635
755
  # overlaps the polygon's envelope.
@@ -665,7 +785,9 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
665
785
  # line or a point; Area() would warn on stdout and return 0 anyway.
666
786
  if overlap.GetDimension() != 2:
667
787
  continue
668
- area = overlap.Area()
788
+ true_area = geometry_area(overlap)
789
+ inside_area += true_area
790
+ area = true_area if area_weighting == 'ellipsoidal' else overlap.Area()
669
791
  if area <= min_area:
670
792
  continue
671
793
 
@@ -680,6 +802,7 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
680
802
 
681
803
  if not index:
682
804
  empty.append(name)
805
+ grid_fraction.append(min(inside_area / polygon_area, 1.0) if polygon_area > 0 else 0.0)
683
806
 
684
807
  weight = np.asarray(weight, dtype=float)
685
808
  if normalize == 'area' and weight.size and weight.sum() > 0:
@@ -698,7 +821,22 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
698
821
  if on_empty == 'warn':
699
822
  warnings.warn(message)
700
823
 
701
- centroids = pd.DataFrame({'x': centroid_x, 'y': centroid_y}, index=names)
824
+ partial = ['%s (%.1f%%)' % (name, 100 * fraction) for name, fraction in zip(names, grid_fraction)
825
+ if name not in empty and fraction < 1 - GRID_FRACTION_TOLERANCE]
826
+ if partial and on_partial != 'ignore':
827
+ message = ('%d zone(s) lie partly outside the grid: %s (share inside). Their means '
828
+ 'cover only that share.' % (len(partial), ', '.join(partial)))
829
+ if on_partial == 'raise':
830
+ raise ValueError(message)
831
+ warnings.warn(message)
832
+
833
+ if wide:
834
+ warnings.warn('%d zone(s) span more than 180 deg of longitude: %s. Polygons crossing '
835
+ 'the antimeridian are not supported; split them at +/-180.'
836
+ % (len(wide), ', '.join(wide)))
837
+
838
+ centroids = pd.DataFrame({'x': centroid_x, 'y': centroid_y,
839
+ 'grid_fraction': grid_fraction}, index=names)
702
840
 
703
841
  return Coverage(
704
842
  zones=names, rows=rows, cols=cols,
@@ -707,6 +845,7 @@ def coverage_from_geometries(latitudes, longitudes, geometries, names=None,
707
845
  weight=(np.concatenate(all_weight) if all_weight else np.zeros(0, float)),
708
846
  centroids=centroids, normalize=normalize, area_weighting=area_weighting,
709
847
  fingerprint=grid_fingerprint(latitudes, longitudes),
848
+ grid_fraction=np.asarray(grid_fraction, dtype=float),
710
849
  )
711
850
 
712
851
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meteoraster
3
- Version: 3.0
3
+ Version: 3.1
4
4
  Summary: Module for handling distributed ensemble and probabilistic forecast data (5-D meteorological rasters)
5
5
  Author-email: Jose Pedro Matos <jose.matos@tecnico.ulisboa.pt>
6
6
  License-Expression: Apache-2.0
@@ -238,10 +238,19 @@ ts = mr.get_values_from_latlon(lat=35.0, lon=0.0)
238
238
 
239
239
  ```python
240
240
  # agg: DataFrame indexed by production date, columns MultiIndex(zone, leadtime, ensemble_member)
241
- # centroids: DataFrame of zone centroids (x, y)
241
+ # centroids: DataFrame of zone centroids (x, y) and grid_fraction (share of the zone on the grid)
242
242
  agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id")
243
+
244
+ # v3.0 numbers (square-degree weights) instead of true WGS84 areas
245
+ agg, centroids = mr.get_values_from_KML("zones.kml", nameField="zone_id",
246
+ area_weighting="planar")
243
247
  ```
244
248
 
249
+ Coordinates are WGS84 lon/lat of cell **centres** (curvilinear 2-D grids included);
250
+ KML edges are straight lines in lon/lat. Weights are true WGS84 areas by default.
251
+ A zone partly outside the grid is averaged over the part inside and warns
252
+ (`on_partial=`); a reused `coverage_info` must match the raster's grid.
253
+
245
254
  Spatial **quantiles** over the same zones, area-weighted so a cell half inside a
246
255
  zone carries half the probability mass:
247
256
 
@@ -543,6 +552,12 @@ changed for any zone touching the edge of the grid: the legacy builder gave the
543
552
  outermost row and column no weight and took its validity threshold from the first
544
553
  zone. `ensemble_member` labels are now 0-based, matching the other extractors.
545
554
 
555
+ Since **v3.1** the default weights are true WGS84 areas (`area_weighting='ellipsoidal'`)
556
+ instead of square degrees. Square degrees over-weight the poleward side of a zone by
557
+ roughly tan(lat) x lat-span: measured 1 % less rain over a Zambezi-sized basin, 0.02 %
558
+ on the Vesdre catchments, 8 % on a single cell weight for a 5 deg zone at 60 N. Pass
559
+ `area_weighting='planar'` to reproduce v3.0 exactly.
560
+
546
561
  **`meteoraster.zonal`** — needs no `MeteoRaster`; takes plain coordinate arrays.
547
562
 
548
563
  | Member | Description |
@@ -553,7 +568,8 @@ zone. `ensemble_member` labels are now 0-based, matching the other extractors.
553
568
  | `quantiles_from_coverage(coverage, values, quantiles, pool_axes=(), …)` | Area-weighted spatial quantiles per zone. |
554
569
  | `weighted_quantiles(values, weights, quantiles, method='hazen')` | The quantile primitive. Reduces exactly to `np.quantile` for equal weights. |
555
570
  | `Coverage.save/load`, `Coverage.matches`, `grid_fingerprint` | Persist a coverage to a pickle-free `.npz` and check it against a grid; the fingerprint hashes the coordinates, so a shifted grid of the same shape is a miss. |
556
- | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. |
571
+ | `cell_corners`, `cell_areas`, `cell_bounds` | The grid geometry, extrapolated half a cell at the boundary so no cell is skipped. `cell_areas(..., 'ellipsoidal')` gives km². |
572
+ | `ring_area`, `geometry_area`, `authalic_latitude` | Exact WGS84 areas (km²) of lon/lat rings and ogr polygons, used by `area_weighting='ellipsoidal'`. |
557
573
 
558
574
  **`meteoraster.io`** — the portable read path.
559
575
 
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "meteoraster"
8
- version = "3.0"
8
+ version = "3.1"
9
9
  description = "Module for handling distributed ensemble and probabilistic forecast data (5-D meteorological rasters)"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.11"
@@ -231,7 +231,8 @@ def test_get_values_from_kml_covers_the_grid_border(grid_raster, grid_kml):
231
231
 
232
232
  pytest.importorskip('osgeo')
233
233
 
234
- agg, centroids = grid_raster.get_values_from_KML(kml=str(grid_kml), nameField='zone_id')
234
+ agg, centroids = grid_raster.get_values_from_KML(kml=str(grid_kml), nameField='zone_id',
235
+ area_weighting='planar')
235
236
 
236
237
  whole = agg.xs('whole', axis=1, level='zone')
237
238
  middle = agg.xs('middle', axis=1, level='zone')
@@ -225,3 +225,15 @@ def test_dateoffset_leadtimes_cannot_be_pooled_with_a_rule(two_zone_kml):
225
225
  two_zone_kml, nameField='zone_id', quantiles=[1.0],
226
226
  pool=('space', 'leadtime'), resampling=None)
227
227
  assert aggregate[('whole', 1.0)].iloc[0] == pytest.approx(20.0)
228
+
229
+
230
+ def test_a_coverage_for_another_grid_is_rejected(grid_raster, two_zone_kml):
231
+ _, _, coverage = grid_raster.get_quantiles_from_KML(kml=str(two_zone_kml), nameField='zone_id',
232
+ getCoverageInfo=True)
233
+ shifted = grid_raster.copy()
234
+ shifted.latitudes = shifted.latitudes + 0.25
235
+
236
+ with pytest.raises(ValueError, match='different grid'):
237
+ shifted.get_quantiles_from_KML(kml=str(two_zone_kml), coverage_info=coverage)
238
+ with pytest.raises(ValueError, match='different grid'):
239
+ shifted.get_values_from_KML(kml=str(two_zone_kml), coverage_info=coverage)
@@ -3,11 +3,15 @@ Numeric tests for meteoraster.zonal on a grid whose weights are computable on pa
3
3
 
4
4
  The 3x3 fixture grid (see conftest) has cells of area exactly 1, spanning
5
5
  lon [j-0.5, j+0.5] and lat [2-i-0.5, 2-i+0.5], so every expected weight below is
6
- an exact fraction rather than a tolerance.
6
+ an exact fraction rather than a tolerance. Those are square-degree weights, so the
7
+ on-paper tests pin area_weighting='planar'; the WGS84 weighting is tested against
8
+ pyproj at the end.
7
9
  '''
8
10
 
9
11
  from __future__ import annotations
10
12
 
13
+ import warnings
14
+
11
15
  import numpy as np
12
16
  import pandas as pd
13
17
  import pytest
@@ -16,6 +20,8 @@ from meteoraster import zonal
16
20
  from conftest import (GRID_LATS, GRID_LONS, RING_WHOLE, RING_MIDDLE, RING_WEST_HALF,
17
21
  RING_ONE_AND_QUARTER, gdal_required, write_kml)
18
22
 
23
+ PLANAR = dict(area_weighting='planar')
24
+
19
25
  WHOLE_WKT = 'POLYGON ((-0.5 -0.5, 2.5 -0.5, 2.5 2.5, -0.5 2.5, -0.5 -0.5))'
20
26
  MIDDLE_WKT = 'POLYGON ((0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))'
21
27
  WEST_HALF_WKT = 'POLYGON ((-0.5 1.5, 0.0 1.5, 0.0 2.5, -0.5 2.5, -0.5 1.5))'
@@ -79,7 +85,8 @@ def test_as_grid_rejects_a_degenerate_grid():
79
85
 
80
86
  @gdal_required
81
87
  def test_whole_grid_zone_catches_every_cell_including_the_corners():
82
- coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'])
88
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'],
89
+ **PLANAR)
83
90
 
84
91
  index, weight = coverage.cells(0)
85
92
 
@@ -174,7 +181,8 @@ def test_halo_does_not_change_the_weights():
174
181
  @gdal_required
175
182
  def test_cos_lat_weighting_rescales_but_keeps_a_uniform_field():
176
183
  values = np.full((1, 3, 3), 4.0)
177
- planar = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'])
184
+ planar = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'],
185
+ **PLANAR)
178
186
  scaled = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'],
179
187
  area_weighting='cos_lat')
180
188
 
@@ -269,7 +277,8 @@ def test_polygon_with_a_hole(tmp_path):
269
277
  '</coordinates></LinearRing></innerBoundaryIs>'
270
278
  '</Polygon></Placemark></Document></kml>\n', encoding='utf-8')
271
279
 
272
- coverage = zonal.coverage_from_kml(path, GRID_LATS, GRID_LONS, name_field='zone_id')
280
+ coverage = zonal.coverage_from_kml(path, GRID_LATS, GRID_LONS, name_field='zone_id',
281
+ **PLANAR)
273
282
 
274
283
  index, weight = coverage.cells(0)
275
284
  assert 4 not in index.tolist() # the centre cell is the hole
@@ -299,7 +308,8 @@ def test_name_field_falls_back_to_the_name_element(tmp_path):
299
308
 
300
309
  @gdal_required
301
310
  def test_area_weighted_mean_uses_the_whole_grid():
302
- coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'])
311
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'],
312
+ **PLANAR)
303
313
  values = np.arange(9, dtype=float).reshape(1, 3, 3)
304
314
 
305
315
  # mean(0..8) = 4.0, not the centre cell's 4 by coincidence: see the asymmetric
@@ -318,7 +328,8 @@ def test_weighted_mean_of_partial_cells():
318
328
 
319
329
  @gdal_required
320
330
  def test_missing_data_is_renormalised_per_zone():
321
- coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'])
331
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'],
332
+ **PLANAR)
322
333
  values = np.arange(9, dtype=float).reshape(1, 3, 3)
323
334
 
324
335
  values[0, 0, 0] = np.nan
@@ -567,3 +578,111 @@ def test_quantiles_from_coverage_respects_min_coverage():
567
578
  got = zonal.quantiles_from_coverage(coverage, values, [0.5], min_coverage=0.8)
568
579
 
569
580
  assert np.isnan(got[0, 0, 0])
581
+
582
+
583
+ # --------------------------------------------------------------------------- #
584
+ # WGS84 area weighting, grid fraction, reuse checks
585
+ # --------------------------------------------------------------------------- #
586
+
587
+ def _geod_rectangle_km2(west, east, south, north):
588
+ '''pyproj area of a lon/lat rectangle, parallels densified so geodesic == straight.'''
589
+ pyproj = pytest.importorskip('pyproj')
590
+ t = np.linspace(0.0, 1.0, 400)
591
+ lons = np.concatenate([west + (east - west) * t, np.full(t.size, east),
592
+ east - (east - west) * t, np.full(t.size, west)])
593
+ lats = np.concatenate([np.full(t.size, south), south + (north - south) * t,
594
+ np.full(t.size, north), north - (north - south) * t])
595
+ area, _ = pyproj.Geod(ellps='WGS84').polygon_area_perimeter(lons, lats)
596
+ return abs(area) / 1e6
597
+
598
+
599
+ @pytest.mark.parametrize('south', [-89.0, -45.0, 0.0, 30.0, 60.0, 75.0, 88.0])
600
+ def test_ellipsoidal_ring_area_matches_pyproj(south):
601
+ expected = _geod_rectangle_km2(10.0, 12.0, south, south + 1)
602
+
603
+ area = zonal.ring_area(np.array([10.0, 12.0, 12.0, 10.0]),
604
+ np.array([south, south, south + 1, south + 1]))
605
+
606
+ assert area == pytest.approx(expected, rel=1e-6)
607
+
608
+
609
+ def test_ellipsoidal_cell_areas_are_true_areas():
610
+ lats = np.array([60.5, 60.0, 59.5])
611
+ lons = np.array([0.0, 0.5, 1.0])
612
+ corner_lat, corner_lon = zonal.cell_corners(lats, lons)
613
+
614
+ areas = zonal.cell_areas(corner_lat, corner_lon, 'ellipsoidal')
615
+
616
+ assert areas[0, 0] == pytest.approx(_geod_rectangle_km2(-0.25, 0.25, 60.25, 60.75), rel=1e-6)
617
+ assert areas[2, 0] == pytest.approx(_geod_rectangle_km2(-0.25, 0.25, 59.25, 59.75), rel=1e-6)
618
+
619
+
620
+ @gdal_required
621
+ def test_ellipsoidal_is_the_default_and_planar_reproduces_v30():
622
+ lats = np.arange(70.0, 49.0, -1.0)
623
+ lons = np.arange(0.0, 6.0, 1.0)
624
+ wkt = 'POLYGON ((0.3 50.3, 5.3 50.3, 5.3 69.3, 0.3 69.3, 0.3 50.3))'
625
+ field = np.broadcast_to(np.meshgrid(lons, lats)[1], (1,) + (lats.size, lons.size))
626
+
627
+ default = zonal.coverage_from_geometries(lats, lons, [wkt], ['z'])
628
+ planar = zonal.coverage_from_geometries(lats, lons, [wkt], ['z'], area_weighting='planar')
629
+
630
+ assert default.area_weighting == 'ellipsoidal'
631
+ # planar is the plain-degree centroid; true areas pull it south (cos lat)
632
+ planar_mean = zonal.apply_coverage(planar, field)[0, 0]
633
+ true_mean = zonal.apply_coverage(default, field)[0, 0]
634
+ assert planar_mean == pytest.approx(59.8, abs=1e-6)
635
+ assert true_mean < planar_mean - 0.5
636
+
637
+
638
+ @gdal_required
639
+ def test_grid_fraction_reports_a_zone_partly_outside_the_grid():
640
+ half_out = 'POLYGON ((1.5 -0.5, 3.5 -0.5, 3.5 2.5, 1.5 2.5, 1.5 -0.5))'
641
+
642
+ with pytest.warns(UserWarning, match='partly outside the grid'):
643
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT, half_out],
644
+ ['all', 'half'])
645
+
646
+ np.testing.assert_allclose(coverage.grid_fraction, [1.0, 0.5], rtol=1e-3)
647
+ assert coverage.centroids.loc['half', 'grid_fraction'] == pytest.approx(0.5, rel=1e-3)
648
+
649
+ with pytest.raises(ValueError, match='partly outside the grid'):
650
+ zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [half_out], ['half'],
651
+ on_partial='raise')
652
+
653
+
654
+ @gdal_required
655
+ def test_a_zone_on_the_grid_border_is_fully_inside():
656
+ with warnings.catch_warnings():
657
+ warnings.simplefilter('error')
658
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT, QUARTER_WKT],
659
+ ['all', 'quarter'])
660
+
661
+ np.testing.assert_allclose(coverage.grid_fraction, 1.0)
662
+
663
+
664
+ @gdal_required
665
+ def test_an_antimeridian_polygon_warns():
666
+ lats = np.array([56.0, 55.0, 54.0])
667
+ lons = np.array([178.0, 179.0, 180.0])
668
+ with pytest.warns(UserWarning, match='antimeridian'):
669
+ zonal.coverage_from_geometries(
670
+ lats, lons, ['POLYGON ((179 54.5, -179 54.5, -179 55.5, 179 55.5, 179 54.5))'],
671
+ ['dateline'], on_partial='ignore')
672
+
673
+
674
+ @gdal_required
675
+ def test_grid_fraction_round_trips_and_old_files_still_load(tmp_path):
676
+ coverage = zonal.coverage_from_geometries(GRID_LATS, GRID_LONS, [WHOLE_WKT], ['all'])
677
+ coverage.save(tmp_path / 'new.npz')
678
+ back = zonal.Coverage.load(tmp_path / 'new.npz')
679
+ np.testing.assert_allclose(back.grid_fraction, coverage.grid_fraction)
680
+ assert 'grid_fraction' in back.centroids.columns
681
+
682
+ # a v3.0 file has no grid_fraction key
683
+ coverage.grid_fraction = None
684
+ coverage.save(tmp_path / 'old.npz')
685
+ old = zonal.Coverage.load(tmp_path / 'old.npz')
686
+ assert old.grid_fraction is None
687
+ np.testing.assert_allclose(old.weight, coverage.weight)
688
+
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes