spatialsample 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialsample/__init__.py +89 -0
- spatialsample/_frame.py +91 -0
- spatialsample/_types.py +36 -0
- spatialsample/accuracy.py +232 -0
- spatialsample/balance.py +144 -0
- spatialsample/bas.py +96 -0
- spatialsample/cli.py +184 -0
- spatialsample/designs.py +562 -0
- spatialsample/estimators.py +222 -0
- spatialsample/geometry.py +253 -0
- spatialsample/grts.py +175 -0
- spatialsample/io.py +161 -0
- spatialsample/lpm.py +133 -0
- spatialsample/probabilities.py +202 -0
- spatialsample/py.typed +0 -0
- spatialsample/raster.py +170 -0
- spatialsample/sample.py +213 -0
- spatialsample-0.1.0.dist-info/METADATA +309 -0
- spatialsample-0.1.0.dist-info/RECORD +23 -0
- spatialsample-0.1.0.dist-info/WHEEL +5 -0
- spatialsample-0.1.0.dist-info/entry_points.txt +2 -0
- spatialsample-0.1.0.dist-info/licenses/LICENSE +21 -0
- spatialsample-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Design-based estimators: Horvitz-Thompson totals and means, with variance
|
|
2
|
+
estimators for independent (SRS-like) and spatially balanced designs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from statistics import NormalDist
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .probabilities import knn_indices
|
|
13
|
+
from .sample import Sample
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ht_total",
|
|
17
|
+
"ht_mean",
|
|
18
|
+
"srs_variance",
|
|
19
|
+
"local_variance",
|
|
20
|
+
"local_weights",
|
|
21
|
+
"estimate",
|
|
22
|
+
"Estimate",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check(y: Any, pi: Any) -> tuple:
|
|
27
|
+
yy = np.asarray(y, dtype=float).ravel()
|
|
28
|
+
pp = np.asarray(pi, dtype=float).ravel()
|
|
29
|
+
if yy.shape != pp.shape:
|
|
30
|
+
raise ValueError("y and pi must have the same length")
|
|
31
|
+
if np.any(pp <= 0):
|
|
32
|
+
raise ValueError("inclusion probabilities/densities must be positive")
|
|
33
|
+
return yy, pp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ht_total(y: Any, pi: Any) -> float:
|
|
37
|
+
"""Horvitz-Thompson estimator of a population total, ``sum(y / pi)``.
|
|
38
|
+
|
|
39
|
+
For continuous designs (``pi`` an inclusion density) this estimates the
|
|
40
|
+
integral of ``y`` over the domain (Cordy 1993).
|
|
41
|
+
"""
|
|
42
|
+
yy, pp = _check(y, pi)
|
|
43
|
+
return float(np.sum(yy / pp))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ht_mean(y: Any, pi: Any, size: Optional[float] = None) -> float:
|
|
47
|
+
"""Mean per unit (or per unit area).
|
|
48
|
+
|
|
49
|
+
With ``size`` (population size ``N`` or domain area) this is the
|
|
50
|
+
Horvitz-Thompson mean ``ht_total / size``; without it, the Hajek ratio
|
|
51
|
+
``sum(y/pi) / sum(1/pi)``.
|
|
52
|
+
"""
|
|
53
|
+
yy, pp = _check(y, pi)
|
|
54
|
+
if size is not None:
|
|
55
|
+
return float(np.sum(yy / pp) / size)
|
|
56
|
+
return float(np.sum(yy / pp) / np.sum(1.0 / pp))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def srs_variance(y: Any, pi: Any) -> float:
|
|
60
|
+
"""Variance of the HT total under the with-replacement approximation.
|
|
61
|
+
|
|
62
|
+
``n / (n - 1) * sum((y_i/pi_i - T/n)^2)``. This is (approximately)
|
|
63
|
+
unbiased for simple random sampling and conservative for spatially
|
|
64
|
+
balanced designs, whose real variance is usually much smaller.
|
|
65
|
+
"""
|
|
66
|
+
yy, pp = _check(y, pi)
|
|
67
|
+
n = len(yy)
|
|
68
|
+
if n < 2:
|
|
69
|
+
raise ValueError("need at least two observations")
|
|
70
|
+
z = yy / pp
|
|
71
|
+
return float(n / (n - 1) * np.sum((z - z.mean()) ** 2))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def local_weights(xy: Any, pi: Any, k: int = 4, iters: int = 500, tol: float = 1e-6) -> tuple:
|
|
75
|
+
"""Neighbourhood weights for the local variance estimator.
|
|
76
|
+
|
|
77
|
+
Each site's neighbourhood holds the site and its ``k - 1`` nearest sample
|
|
78
|
+
neighbours. Initial weights are proportional to ``1/pi_j`` and are then
|
|
79
|
+
balanced by iterative proportional fitting so that every row and every
|
|
80
|
+
column of the weight matrix sums to one (Stevens & Olsen 2003); rows are
|
|
81
|
+
exact, columns converge to one within a small tolerance.
|
|
82
|
+
|
|
83
|
+
Returns ``(neighbours, weights)``, both of shape ``(n, k)``.
|
|
84
|
+
"""
|
|
85
|
+
pts = np.asarray(xy, dtype=float).reshape(-1, 2)
|
|
86
|
+
pp = np.asarray(pi, dtype=float).ravel()
|
|
87
|
+
nb = knn_indices(pts, k)
|
|
88
|
+
w = 1.0 / pp[nb]
|
|
89
|
+
flat = nb.ravel()
|
|
90
|
+
for _ in range(iters):
|
|
91
|
+
w = w / w.sum(axis=1, keepdims=True)
|
|
92
|
+
col = np.bincount(flat, weights=w.ravel(), minlength=len(pts))
|
|
93
|
+
if np.max(np.abs(col - 1.0)) < tol:
|
|
94
|
+
break
|
|
95
|
+
w = w / col[nb]
|
|
96
|
+
w = w / w.sum(axis=1, keepdims=True)
|
|
97
|
+
return nb, w
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def local_variance(y: Any, xy: Any, pi: Any, k: int = 4, correction: bool = True) -> float:
|
|
101
|
+
"""Local neighbourhood variance estimator for spatially balanced samples.
|
|
102
|
+
|
|
103
|
+
Implements the neighbourhood estimator of Stevens & Olsen (2003):
|
|
104
|
+
|
|
105
|
+
``V = sum_i sum_{j in D(i)} w_ij (y_j/pi_j - zbar_i)^2``,
|
|
106
|
+
``zbar_i = sum_{j in D(i)} w_ij y_j/pi_j``
|
|
107
|
+
|
|
108
|
+
where ``D(i)`` is the site and its ``k - 1`` nearest sample neighbours and
|
|
109
|
+
the weights are doubly stochastic (:func:`local_weights`). Because nearby
|
|
110
|
+
sites are compared, smooth spatial trends do not inflate the estimate,
|
|
111
|
+
unlike :func:`srs_variance`.
|
|
112
|
+
|
|
113
|
+
With ``correction=True`` (default) each neighbourhood term is divided by
|
|
114
|
+
``1 - sum_j w_ij^2``, the usual small-sample correction for a weighted
|
|
115
|
+
variance, which makes the estimator unbiased when the population has no
|
|
116
|
+
spatial structure; with ``correction=False`` the uncorrected form is
|
|
117
|
+
returned (smaller by about ``(k - 1) / k``).
|
|
118
|
+
"""
|
|
119
|
+
yy, pp = _check(y, pi)
|
|
120
|
+
if len(yy) < 3:
|
|
121
|
+
raise ValueError("need at least three observations")
|
|
122
|
+
nb, w = local_weights(xy, pp, k)
|
|
123
|
+
z = yy / pp
|
|
124
|
+
zn = z[nb]
|
|
125
|
+
zbar = np.sum(w * zn, axis=1, keepdims=True)
|
|
126
|
+
terms = np.sum(w * (zn - zbar) ** 2, axis=1)
|
|
127
|
+
if correction:
|
|
128
|
+
terms = terms / np.maximum(1.0 - np.sum(w**2, axis=1), 1e-12)
|
|
129
|
+
return float(np.sum(terms))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Estimate:
|
|
134
|
+
"""Result of :func:`estimate`."""
|
|
135
|
+
|
|
136
|
+
total: float
|
|
137
|
+
total_se: float
|
|
138
|
+
mean: float
|
|
139
|
+
mean_se: float
|
|
140
|
+
ci_low: float
|
|
141
|
+
ci_high: float
|
|
142
|
+
n: int
|
|
143
|
+
variance: str
|
|
144
|
+
conf: float
|
|
145
|
+
|
|
146
|
+
def as_dict(self) -> dict:
|
|
147
|
+
return dict(self.__dict__)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def estimate(
|
|
151
|
+
sample: Sample,
|
|
152
|
+
y: Any,
|
|
153
|
+
variance: str = "auto",
|
|
154
|
+
conf: float = 0.95,
|
|
155
|
+
size: Optional[float] = None,
|
|
156
|
+
k: int = 4,
|
|
157
|
+
mean_type: str = "ratio",
|
|
158
|
+
) -> Estimate:
|
|
159
|
+
"""Horvitz-Thompson total and mean of ``y`` with a standard error and CI.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
sample:
|
|
164
|
+
A :class:`~spatialsample.Sample`; only base sites should be passed
|
|
165
|
+
(use ``sample.base()``) unless the weights were adjusted.
|
|
166
|
+
y:
|
|
167
|
+
Observed values at the sample sites.
|
|
168
|
+
variance:
|
|
169
|
+
``"srs"``, ``"local"`` or ``"auto"`` (local for GRTS, BAS, LPM and
|
|
170
|
+
systematic designs, SRS otherwise).
|
|
171
|
+
size:
|
|
172
|
+
Population size or domain area used by ``mean_type="ht"``; defaults
|
|
173
|
+
to ``meta["population_size"]`` or ``meta["area"]``.
|
|
174
|
+
mean_type:
|
|
175
|
+
``"ratio"`` (default): the Hajek mean ``sum(y/pi) / sum(1/pi)`` with a
|
|
176
|
+
linearised standard error, which removes the extra variance of
|
|
177
|
+
random-size designs (systematic grids, clusters). ``"ht"``: the
|
|
178
|
+
Horvitz-Thompson total divided by ``size``.
|
|
179
|
+
|
|
180
|
+
The confidence interval (``ci_low``, ``ci_high``) is for the total.
|
|
181
|
+
"""
|
|
182
|
+
yy = np.asarray(y, dtype=float).ravel()
|
|
183
|
+
if len(yy) != len(sample):
|
|
184
|
+
raise ValueError("y must have one value per sample site")
|
|
185
|
+
if variance == "auto":
|
|
186
|
+
balanced = sample.design.startswith(("grts", "bas", "lpm", "systematic"))
|
|
187
|
+
variance = "local" if balanced and len(yy) >= 3 else "srs"
|
|
188
|
+
if variance == "local":
|
|
189
|
+
var = local_variance(yy, sample.xy, sample.pi, k=k)
|
|
190
|
+
elif variance == "srs":
|
|
191
|
+
var = srs_variance(yy, sample.pi)
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError("variance must be 'srs', 'local' or 'auto'")
|
|
194
|
+
total = ht_total(yy, sample.pi)
|
|
195
|
+
se = float(np.sqrt(max(var, 0.0)))
|
|
196
|
+
if size is None:
|
|
197
|
+
size = sample.meta.get("population_size", sample.meta.get("area"))
|
|
198
|
+
zq = NormalDist().inv_cdf(0.5 + conf / 2)
|
|
199
|
+
if mean_type not in ("ratio", "ht"):
|
|
200
|
+
raise ValueError("mean_type must be 'ratio' or 'ht'")
|
|
201
|
+
if mean_type == "ht" and size:
|
|
202
|
+
mean, mean_se = total / size, se / size
|
|
203
|
+
else:
|
|
204
|
+
# Hajek ratio mean with a linearised (residual) variance.
|
|
205
|
+
mean = ht_mean(yy, sample.pi)
|
|
206
|
+
resid = yy - mean
|
|
207
|
+
if variance == "local":
|
|
208
|
+
rvar = local_variance(resid, sample.xy, sample.pi, k=k)
|
|
209
|
+
else:
|
|
210
|
+
rvar = srs_variance(resid, sample.pi)
|
|
211
|
+
mean_se = float(np.sqrt(max(rvar, 0.0)) / np.sum(1.0 / sample.pi))
|
|
212
|
+
return Estimate(
|
|
213
|
+
total=total,
|
|
214
|
+
total_se=se,
|
|
215
|
+
mean=mean,
|
|
216
|
+
mean_se=mean_se,
|
|
217
|
+
ci_low=total - zq * se,
|
|
218
|
+
ci_high=total + zq * se,
|
|
219
|
+
n=len(yy),
|
|
220
|
+
variance=variance,
|
|
221
|
+
conf=conf,
|
|
222
|
+
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Planar polygon regions with holes and multiple parts, implemented in pure NumPy.
|
|
2
|
+
|
|
3
|
+
A :class:`Region` is the sampling domain for every continuous design. It is
|
|
4
|
+
built from GeoJSON (``Polygon``, ``MultiPolygon``, ``Feature``,
|
|
5
|
+
``FeatureCollection`` or ``GeometryCollection``), from any object that exposes
|
|
6
|
+
``__geo_interface__`` (Shapely geometries, GeoPandas objects) or directly from
|
|
7
|
+
coordinate arrays.
|
|
8
|
+
|
|
9
|
+
Coordinates are treated as planar. Distance-based designs (systematic grids,
|
|
10
|
+
Poisson-disk, GRTS, LPM, the local variance estimator and the spatial balance
|
|
11
|
+
metric) assume a projected coordinate reference system in metres or another
|
|
12
|
+
linear unit; reproject longitude/latitude data before sampling.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ._types import FloatArray, SeedLike, as_rng
|
|
22
|
+
|
|
23
|
+
__all__ = ["Polygon", "Region", "ring_area", "points_in_ring"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_ring(coords: Any) -> FloatArray:
|
|
27
|
+
ring = np.asarray(coords, dtype=float)
|
|
28
|
+
if ring.ndim != 2 or ring.shape[1] < 2:
|
|
29
|
+
raise ValueError("a ring must be an (m, 2) sequence of coordinates")
|
|
30
|
+
ring = ring[:, :2]
|
|
31
|
+
if len(ring) > 1 and np.allclose(ring[0], ring[-1]):
|
|
32
|
+
ring = ring[:-1]
|
|
33
|
+
if len(ring) < 3:
|
|
34
|
+
raise ValueError("a ring needs at least three distinct vertices")
|
|
35
|
+
if not np.all(np.isfinite(ring)):
|
|
36
|
+
raise ValueError("ring coordinates must be finite")
|
|
37
|
+
return ring
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ring_area(ring: FloatArray) -> float:
|
|
41
|
+
"""Signed shoelace area of an open ring (positive when counter-clockwise)."""
|
|
42
|
+
x = ring[:, 0]
|
|
43
|
+
y = ring[:, 1]
|
|
44
|
+
return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def points_in_ring(xy: FloatArray, ring: FloatArray) -> np.ndarray:
|
|
48
|
+
"""Even-odd (crossing number) test of many points against one ring."""
|
|
49
|
+
px = xy[:, 0]
|
|
50
|
+
py = xy[:, 1]
|
|
51
|
+
inside = np.zeros(len(xy), dtype=bool)
|
|
52
|
+
x0 = ring[:, 0]
|
|
53
|
+
y0 = ring[:, 1]
|
|
54
|
+
x1 = np.roll(x0, -1)
|
|
55
|
+
y1 = np.roll(y0, -1)
|
|
56
|
+
for k in range(len(ring)):
|
|
57
|
+
ya, yb = y0[k], y1[k]
|
|
58
|
+
if ya == yb:
|
|
59
|
+
continue
|
|
60
|
+
cond = (ya > py) != (yb > py)
|
|
61
|
+
if not cond.any():
|
|
62
|
+
continue
|
|
63
|
+
xa, xb = x0[k], x1[k]
|
|
64
|
+
xint = xa + (py - ya) * (xb - xa) / (yb - ya)
|
|
65
|
+
inside ^= cond & (px < xint)
|
|
66
|
+
return inside
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Polygon:
|
|
70
|
+
"""A single polygon: one exterior ring and any number of holes."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, exterior: Any, holes: Optional[Iterable[Any]] = None) -> None:
|
|
73
|
+
self.exterior: FloatArray = _as_ring(exterior)
|
|
74
|
+
self.holes: List[FloatArray] = [_as_ring(h) for h in (holes or [])]
|
|
75
|
+
rings = [self.exterior, *self.holes]
|
|
76
|
+
allpts = np.vstack(rings)
|
|
77
|
+
self.bounds: Tuple[float, float, float, float] = (
|
|
78
|
+
float(allpts[:, 0].min()),
|
|
79
|
+
float(allpts[:, 1].min()),
|
|
80
|
+
float(allpts[:, 0].max()),
|
|
81
|
+
float(allpts[:, 1].max()),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def area(self) -> float:
|
|
86
|
+
a = abs(ring_area(self.exterior))
|
|
87
|
+
for h in self.holes:
|
|
88
|
+
a -= abs(ring_area(h))
|
|
89
|
+
return a
|
|
90
|
+
|
|
91
|
+
def contains(self, xy: Any) -> np.ndarray:
|
|
92
|
+
pts = np.atleast_2d(np.asarray(xy, dtype=float))
|
|
93
|
+
x0, y0, x1, y1 = self.bounds
|
|
94
|
+
out = np.zeros(len(pts), dtype=bool)
|
|
95
|
+
box = (pts[:, 0] >= x0) & (pts[:, 0] <= x1) & (pts[:, 1] >= y0) & (pts[:, 1] <= y1)
|
|
96
|
+
if not box.any():
|
|
97
|
+
return out
|
|
98
|
+
sub = pts[box]
|
|
99
|
+
inside = points_in_ring(sub, self.exterior)
|
|
100
|
+
for h in self.holes:
|
|
101
|
+
inside &= ~points_in_ring(sub, h)
|
|
102
|
+
out[box] = inside
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
def to_coordinates(self) -> List[List[List[float]]]:
|
|
106
|
+
rings = []
|
|
107
|
+
for k, r in enumerate([self.exterior, *self.holes]):
|
|
108
|
+
ccw = ring_area(r) > 0
|
|
109
|
+
# RFC 7946: exterior counter-clockwise, holes clockwise.
|
|
110
|
+
if (k == 0) != ccw:
|
|
111
|
+
r = r[::-1]
|
|
112
|
+
closed = np.vstack([r, r[:1]])
|
|
113
|
+
rings.append(closed.tolist())
|
|
114
|
+
return rings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Region:
|
|
118
|
+
"""A planar sampling domain made of one or more non-overlapping polygons.
|
|
119
|
+
|
|
120
|
+
Parts are combined by union for point-in-region tests; the area is the sum
|
|
121
|
+
of part areas, so parts should not overlap.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, polygons: Sequence[Polygon]) -> None:
|
|
125
|
+
if len(polygons) == 0:
|
|
126
|
+
raise ValueError("a Region needs at least one polygon")
|
|
127
|
+
self.polygons: List[Polygon] = list(polygons)
|
|
128
|
+
b = np.array([p.bounds for p in self.polygons])
|
|
129
|
+
self.bounds: Tuple[float, float, float, float] = (
|
|
130
|
+
float(b[:, 0].min()),
|
|
131
|
+
float(b[:, 1].min()),
|
|
132
|
+
float(b[:, 2].max()),
|
|
133
|
+
float(b[:, 3].max()),
|
|
134
|
+
)
|
|
135
|
+
self._area = float(sum(p.area for p in self.polygons))
|
|
136
|
+
if self._area <= 0:
|
|
137
|
+
raise ValueError("region has zero or negative area")
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ constructors
|
|
140
|
+
@classmethod
|
|
141
|
+
def box(cls, xmin: float, ymin: float, xmax: float, ymax: float) -> Region:
|
|
142
|
+
"""Axis-aligned rectangle."""
|
|
143
|
+
if xmax <= xmin or ymax <= ymin:
|
|
144
|
+
raise ValueError("box needs xmax > xmin and ymax > ymin")
|
|
145
|
+
ring = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]
|
|
146
|
+
return cls([Polygon(ring)])
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_coords(cls, exterior: Any, holes: Optional[Iterable[Any]] = None) -> Region:
|
|
150
|
+
"""Single polygon from an exterior ring and optional hole rings."""
|
|
151
|
+
return cls([Polygon(exterior, holes)])
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_geojson(cls, obj: Mapping[str, Any]) -> Region:
|
|
155
|
+
"""Build from a GeoJSON geometry, Feature, FeatureCollection or GeometryCollection.
|
|
156
|
+
|
|
157
|
+
Non-areal geometries (points, lines) inside collections are ignored.
|
|
158
|
+
"""
|
|
159
|
+
polys = list(_iter_polygons(obj))
|
|
160
|
+
if not polys:
|
|
161
|
+
raise ValueError("no Polygon or MultiPolygon geometry found")
|
|
162
|
+
return cls(polys)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_shapely(cls, geom: Any) -> Region:
|
|
166
|
+
"""Build from any object exposing ``__geo_interface__`` (Shapely, GeoPandas)."""
|
|
167
|
+
gi = getattr(geom, "__geo_interface__", None)
|
|
168
|
+
if gi is None:
|
|
169
|
+
raise TypeError("object does not expose __geo_interface__")
|
|
170
|
+
return cls.from_geojson(gi)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ properties
|
|
173
|
+
@property
|
|
174
|
+
def area(self) -> float:
|
|
175
|
+
return self._area
|
|
176
|
+
|
|
177
|
+
def contains(self, xy: Any) -> np.ndarray:
|
|
178
|
+
"""Boolean mask of points that fall inside the region (holes excluded)."""
|
|
179
|
+
pts = np.atleast_2d(np.asarray(xy, dtype=float))
|
|
180
|
+
out = np.zeros(len(pts), dtype=bool)
|
|
181
|
+
for p in self.polygons:
|
|
182
|
+
todo = ~out
|
|
183
|
+
if todo.any():
|
|
184
|
+
out[todo] = p.contains(pts[todo])
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------ point generators
|
|
188
|
+
def random_points(self, n: int, seed: SeedLike = None) -> FloatArray:
|
|
189
|
+
"""``n`` independent uniform points inside the region (rejection sampling)."""
|
|
190
|
+
return _rejection_points(self, n, as_rng(seed))
|
|
191
|
+
|
|
192
|
+
def to_geojson(self) -> dict:
|
|
193
|
+
"""GeoJSON geometry (``Polygon`` or ``MultiPolygon``)."""
|
|
194
|
+
if len(self.polygons) == 1:
|
|
195
|
+
return {"type": "Polygon", "coordinates": self.polygons[0].to_coordinates()}
|
|
196
|
+
return {
|
|
197
|
+
"type": "MultiPolygon",
|
|
198
|
+
"coordinates": [p.to_coordinates() for p in self.polygons],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def __geo_interface__(self) -> dict:
|
|
203
|
+
return self.to_geojson()
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
nh = sum(len(p.holes) for p in self.polygons)
|
|
207
|
+
return f"Region(parts={len(self.polygons)}, holes={nh}, area={self.area:.6g})"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _iter_polygons(obj: Mapping[str, Any]) -> Iterator[Polygon]:
|
|
211
|
+
if hasattr(obj, "__geo_interface__") and not isinstance(obj, Mapping):
|
|
212
|
+
obj = obj.__geo_interface__
|
|
213
|
+
t = obj.get("type")
|
|
214
|
+
if t == "FeatureCollection":
|
|
215
|
+
for f in obj.get("features", []):
|
|
216
|
+
yield from _iter_polygons(f)
|
|
217
|
+
elif t == "Feature":
|
|
218
|
+
g = obj.get("geometry")
|
|
219
|
+
if g:
|
|
220
|
+
yield from _iter_polygons(g)
|
|
221
|
+
elif t == "GeometryCollection":
|
|
222
|
+
for g in obj.get("geometries", []):
|
|
223
|
+
yield from _iter_polygons(g)
|
|
224
|
+
elif t == "Polygon":
|
|
225
|
+
rings = obj["coordinates"]
|
|
226
|
+
if rings:
|
|
227
|
+
yield Polygon(rings[0], rings[1:])
|
|
228
|
+
elif t == "MultiPolygon":
|
|
229
|
+
for rings in obj["coordinates"]:
|
|
230
|
+
if rings:
|
|
231
|
+
yield Polygon(rings[0], rings[1:])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _rejection_points(domain: Any, n: int, rng: np.random.Generator) -> FloatArray:
|
|
235
|
+
if n < 0:
|
|
236
|
+
raise ValueError("n must be non-negative")
|
|
237
|
+
if n == 0:
|
|
238
|
+
return np.empty((0, 2))
|
|
239
|
+
x0, y0, x1, y1 = domain.bounds
|
|
240
|
+
frac = max(domain.area / max((x1 - x0) * (y1 - y0), 1e-300), 1e-6)
|
|
241
|
+
out: List[FloatArray] = []
|
|
242
|
+
have = 0
|
|
243
|
+
for _ in range(10000):
|
|
244
|
+
m = int(min(max(64, 1.3 * (n - have) / frac), 5_000_000))
|
|
245
|
+
cand = np.column_stack([rng.uniform(x0, x1, m), rng.uniform(y0, y1, m)])
|
|
246
|
+
cand = cand[domain.contains(cand)]
|
|
247
|
+
out.append(cand)
|
|
248
|
+
have += len(cand)
|
|
249
|
+
if have >= n:
|
|
250
|
+
break
|
|
251
|
+
else: # pragma: no cover - only for pathological slivers
|
|
252
|
+
raise RuntimeError("rejection sampling failed; is the domain degenerate?")
|
|
253
|
+
return np.vstack(out)[:n]
|
spatialsample/grts.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Generalized Random Tessellation Stratified (GRTS) sampling.
|
|
2
|
+
|
|
3
|
+
Implements the design of Stevens & Olsen (2004):
|
|
4
|
+
|
|
5
|
+
1. The frame is covered by a randomly shifted square that is split
|
|
6
|
+
recursively into quadrants. At every level the four quadrant labels of each
|
|
7
|
+
parent cell are randomly permuted (*hierarchical address randomisation*),
|
|
8
|
+
and splitting stops once no cell holds more than one unit of inclusion
|
|
9
|
+
probability.
|
|
10
|
+
2. Units are ordered by their randomised base-4 address, which maps the plane
|
|
11
|
+
onto a line while preserving proximity.
|
|
12
|
+
3. A systematic sample with a random start is taken along that line, with
|
|
13
|
+
step one in cumulative inclusion probability, so each unit is selected with
|
|
14
|
+
exactly its prescribed probability.
|
|
15
|
+
4. The selected sites are put in *reverse hierarchical order* (base-4 digit
|
|
16
|
+
reversal of their rank), so that any leading subset of the list is itself
|
|
17
|
+
spatially balanced. This is what makes oversample (replacement) sites
|
|
18
|
+
usable: visit base sites, then take replacements in order.
|
|
19
|
+
|
|
20
|
+
Continuous areas are handled, as in recent versions of the R package
|
|
21
|
+
spsurvey, through a dense finite approximation: a randomly offset grid of
|
|
22
|
+
cell centres is sampled with GRTS and each selected centre is then moved to a
|
|
23
|
+
uniform random location inside its cell.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from ._frame import ProbLike, frame_sizes, grid_frame, jitter_in_cells, resolve
|
|
34
|
+
from ._types import FloatArray, SeedLike, as_rng
|
|
35
|
+
from .probabilities import inclusion_probabilities, systematic_pps
|
|
36
|
+
from .sample import Sample
|
|
37
|
+
|
|
38
|
+
__all__ = ["grts", "grts_order", "reverse_hierarchical_order"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def grts_order(
|
|
42
|
+
xy: FloatArray, pi: FloatArray, rng: np.random.Generator, max_level: int = 30
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""Randomised hierarchical (quadrant-recursive) ordering of frame units.
|
|
45
|
+
|
|
46
|
+
Returns the permutation that sorts units by their randomised base-4
|
|
47
|
+
address; units that share a finest-level cell are ordered randomly.
|
|
48
|
+
"""
|
|
49
|
+
p = np.asarray(xy, dtype=float)
|
|
50
|
+
n_units = len(p)
|
|
51
|
+
if n_units == 0:
|
|
52
|
+
return np.empty(0, dtype=np.int64)
|
|
53
|
+
lo = p.min(axis=0)
|
|
54
|
+
span = float(np.max(p.max(axis=0) - lo))
|
|
55
|
+
span = span if span > 0 else 1.0
|
|
56
|
+
side = 2.0 * span * (1.0 + 1e-9)
|
|
57
|
+
# Random placement of the bounding square: every unit falls inside.
|
|
58
|
+
origin = lo - rng.uniform(0.0, span, 2)
|
|
59
|
+
u = (p - origin) / side
|
|
60
|
+
address = np.zeros(n_units, dtype=np.int64)
|
|
61
|
+
weights = np.asarray(pi, dtype=float)
|
|
62
|
+
for level in range(1, min(max_level, 30) + 1):
|
|
63
|
+
k = 1 << level
|
|
64
|
+
ix = np.minimum((u[:, 0] * k).astype(np.int64), k - 1)
|
|
65
|
+
iy = np.minimum((u[:, 1] * k).astype(np.int64), k - 1)
|
|
66
|
+
quad = (ix & 1) + 2 * (iy & 1)
|
|
67
|
+
parents, inv = np.unique(address, return_inverse=True)
|
|
68
|
+
inv = inv.ravel()
|
|
69
|
+
perms = np.argsort(rng.random((len(parents), 4)), axis=1)
|
|
70
|
+
address = address * 4 + perms[inv, quad]
|
|
71
|
+
_, cell = np.unique(address, return_inverse=True)
|
|
72
|
+
cell_mass = np.bincount(cell.ravel(), weights=weights)
|
|
73
|
+
if float(np.max(cell_mass)) <= 1.0 + 1e-12:
|
|
74
|
+
break
|
|
75
|
+
tie = rng.random(n_units)
|
|
76
|
+
return np.lexsort((tie, address))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def reverse_hierarchical_order(m: int) -> np.ndarray:
|
|
80
|
+
"""Permutation of ``range(m)`` by base-4 digit reversal of the rank."""
|
|
81
|
+
if m <= 1:
|
|
82
|
+
return np.arange(m)
|
|
83
|
+
digits = max(1, int(math.ceil(math.log(m, 4) - 1e-12)))
|
|
84
|
+
k = np.arange(m)
|
|
85
|
+
key = np.zeros(m, dtype=np.int64)
|
|
86
|
+
rest = k.copy()
|
|
87
|
+
for _ in range(digits):
|
|
88
|
+
key = key * 4 + (rest % 4)
|
|
89
|
+
rest //= 4
|
|
90
|
+
return np.argsort(key, kind="stable")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _grts_select(sizes: FloatArray, n: int, n_over: int, xy: FloatArray, rng: np.random.Generator):
|
|
94
|
+
m = n + n_over
|
|
95
|
+
pi_base = inclusion_probabilities(sizes, n)
|
|
96
|
+
pi_all = inclusion_probabilities(sizes, m) if n_over else pi_base
|
|
97
|
+
order = grts_order(xy, pi_all, rng)
|
|
98
|
+
pos = systematic_pps(pi_all[order], rng)
|
|
99
|
+
chosen = order[pos] # in hierarchical order
|
|
100
|
+
chosen = chosen[reverse_hierarchical_order(len(chosen))]
|
|
101
|
+
site_type = np.array(["base"] * n + ["over"] * n_over, dtype=object)
|
|
102
|
+
return chosen, pi_base, site_type
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def grts(
|
|
106
|
+
frame: Any,
|
|
107
|
+
n: int,
|
|
108
|
+
n_over: int = 0,
|
|
109
|
+
prob: ProbLike = None,
|
|
110
|
+
frame_size: Optional[int] = None,
|
|
111
|
+
seed: SeedLike = None,
|
|
112
|
+
) -> Sample:
|
|
113
|
+
"""Draw a GRTS sample.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
frame:
|
|
118
|
+
A :class:`~spatialsample.Region`, :class:`~spatialsample.RasterMask`,
|
|
119
|
+
GeoJSON polygon mapping (continuous design) or an ``(N, 2)`` array of
|
|
120
|
+
candidate sites (finite design).
|
|
121
|
+
n:
|
|
122
|
+
Base sample size.
|
|
123
|
+
n_over:
|
|
124
|
+
Number of oversample / replacement sites, returned after the base
|
|
125
|
+
sites in reverse hierarchical order.
|
|
126
|
+
prob:
|
|
127
|
+
Size measure for unequal-probability sampling: an array with one value
|
|
128
|
+
per frame point, or (continuous designs) a callable evaluated at grid
|
|
129
|
+
cell centres. Inclusion probabilities are proportional to it.
|
|
130
|
+
frame_size:
|
|
131
|
+
Number of grid cells used to approximate a continuous domain
|
|
132
|
+
(default ``max(4000, 100 * (n + n_over))``).
|
|
133
|
+
seed:
|
|
134
|
+
Seed or ``numpy.random.Generator`` for reproducibility.
|
|
135
|
+
"""
|
|
136
|
+
rng = as_rng(seed)
|
|
137
|
+
if n < 1 or n_over < 0:
|
|
138
|
+
raise ValueError("n must be >= 1 and n_over >= 0")
|
|
139
|
+
domain, pts = resolve(frame)
|
|
140
|
+
m = n + n_over
|
|
141
|
+
if pts is not None:
|
|
142
|
+
sizes = frame_sizes(prob, pts)
|
|
143
|
+
chosen, pi_base, site_type = _grts_select(sizes, n, n_over, pts, rng)
|
|
144
|
+
pi = pi_base[chosen]
|
|
145
|
+
return Sample(
|
|
146
|
+
xy=pts[chosen],
|
|
147
|
+
pi=pi,
|
|
148
|
+
weight=1.0 / pi,
|
|
149
|
+
design="grts",
|
|
150
|
+
kind="finite",
|
|
151
|
+
site_type=site_type,
|
|
152
|
+
order=np.arange(m),
|
|
153
|
+
index=chosen,
|
|
154
|
+
meta={"n": n, "n_over": n_over, "population_size": len(pts)},
|
|
155
|
+
)
|
|
156
|
+
assert domain is not None
|
|
157
|
+
size = frame_size or max(4000, 100 * m)
|
|
158
|
+
cells, h = grid_frame(domain, size, rng, min_points=2 * m)
|
|
159
|
+
sizes = frame_sizes(prob, cells)
|
|
160
|
+
chosen, pi_base, site_type = _grts_select(sizes, n, n_over, cells, rng)
|
|
161
|
+
xy = jitter_in_cells(cells[chosen], h, domain, rng)
|
|
162
|
+
if prob is None:
|
|
163
|
+
density = np.full(m, n / domain.area)
|
|
164
|
+
else:
|
|
165
|
+
density = pi_base[chosen] / (h * h)
|
|
166
|
+
return Sample(
|
|
167
|
+
xy=xy,
|
|
168
|
+
pi=density,
|
|
169
|
+
weight=1.0 / density,
|
|
170
|
+
design="grts",
|
|
171
|
+
kind="continuous",
|
|
172
|
+
site_type=site_type,
|
|
173
|
+
order=np.arange(m),
|
|
174
|
+
meta={"n": n, "n_over": n_over, "area": domain.area, "frame_size": len(cells)},
|
|
175
|
+
)
|