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.
@@ -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
+ )