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.
- {meteoraster-3.0/meteoraster.egg-info → meteoraster-3.1}/PKG-INFO +19 -3
- {meteoraster-3.0 → meteoraster-3.1}/README.md +18 -2
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/meteoraster.py +44 -5
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/zonal.py +153 -14
- {meteoraster-3.0 → meteoraster-3.1/meteoraster.egg-info}/PKG-INFO +19 -3
- {meteoraster-3.0 → meteoraster-3.1}/pyproject.toml +1 -1
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_base.py +2 -1
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_quantiles.py +12 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_zonal.py +125 -6
- {meteoraster-3.0 → meteoraster-3.1}/LICENSE +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/MANIFEST.in +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/NOTICE +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/__init__.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/_lazy.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/io.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/resample.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster/utils.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/SOURCES.txt +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/dependency_links.txt +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/requires.txt +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/meteoraster.egg-info/top_level.txt +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/setup.cfg +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/conftest.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_deprecations.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_io.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_kml_catchments.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_extra.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_meteo_raster_plots.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_real_rasters.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_regressions.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_resample.py +0 -0
- {meteoraster-3.0 → meteoraster-3.1}/tests/test_resample_semantics.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
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 = ('
|
|
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)
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|