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
spatialsample/designs.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Classical and field designs: simple random, systematic, stratified, Poisson-disk,
|
|
2
|
+
cluster, two-stage and transect sampling."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._frame import as_domain
|
|
12
|
+
from ._types import Domain, FloatArray, SeedLike, as_rng
|
|
13
|
+
from .bas import bas
|
|
14
|
+
from .grts import grts
|
|
15
|
+
from .lpm import lpm
|
|
16
|
+
from .probabilities import allocate, inclusion_probabilities, systematic_pps
|
|
17
|
+
from .raster import RasterMask
|
|
18
|
+
from .sample import Sample
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"srs",
|
|
22
|
+
"systematic",
|
|
23
|
+
"stratified",
|
|
24
|
+
"stratified_pixels",
|
|
25
|
+
"poisson_disk",
|
|
26
|
+
"cluster",
|
|
27
|
+
"two_stage",
|
|
28
|
+
"points_along_lines",
|
|
29
|
+
"random_transects",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def srs(domain: Any, n: int, seed: SeedLike = None) -> Sample:
|
|
34
|
+
"""Simple random sample: ``n`` independent uniform locations in the domain."""
|
|
35
|
+
dom = as_domain(domain)
|
|
36
|
+
if n < 1:
|
|
37
|
+
raise ValueError("n must be >= 1")
|
|
38
|
+
xy = dom.random_points(n, as_rng(seed))
|
|
39
|
+
density = n / dom.area
|
|
40
|
+
return Sample(
|
|
41
|
+
xy=xy,
|
|
42
|
+
pi=np.full(n, density),
|
|
43
|
+
weight=np.full(n, 1.0 / density),
|
|
44
|
+
design="srs",
|
|
45
|
+
meta={"n": n, "area": dom.area},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def systematic(
|
|
50
|
+
domain: Any,
|
|
51
|
+
n: Optional[int] = None,
|
|
52
|
+
spacing: Optional[float] = None,
|
|
53
|
+
shape: str = "square",
|
|
54
|
+
rotation: Union[float, str, None] = 0.0,
|
|
55
|
+
seed: SeedLike = None,
|
|
56
|
+
) -> Sample:
|
|
57
|
+
"""Systematic grid with a uniformly random start.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
n:
|
|
62
|
+
Target sample size; the spacing is chosen so that the expected number
|
|
63
|
+
of points in the domain is ``n``. The realised size varies slightly.
|
|
64
|
+
spacing:
|
|
65
|
+
Distance between neighbouring grid points (overrides ``n``).
|
|
66
|
+
shape:
|
|
67
|
+
``"square"`` or ``"hex"`` (triangular lattice, hexagonal cells).
|
|
68
|
+
rotation:
|
|
69
|
+
Grid rotation in degrees, or ``"random"`` for a uniformly random angle.
|
|
70
|
+
"""
|
|
71
|
+
dom = as_domain(domain)
|
|
72
|
+
rng = as_rng(seed)
|
|
73
|
+
shape = shape.lower()
|
|
74
|
+
if shape not in ("square", "hex"):
|
|
75
|
+
raise ValueError("shape must be 'square' or 'hex'")
|
|
76
|
+
if spacing is None:
|
|
77
|
+
if n is None or n < 1:
|
|
78
|
+
raise ValueError("give n >= 1 or spacing")
|
|
79
|
+
cell = dom.area / n
|
|
80
|
+
spacing = math.sqrt(cell) if shape == "square" else math.sqrt(2.0 * cell / math.sqrt(3.0))
|
|
81
|
+
if spacing <= 0:
|
|
82
|
+
raise ValueError("spacing must be positive")
|
|
83
|
+
s = float(spacing)
|
|
84
|
+
row_h = s if shape == "square" else s * math.sqrt(3.0) / 2.0
|
|
85
|
+
cell_area = s * row_h
|
|
86
|
+
if rotation == "random":
|
|
87
|
+
theta = rng.uniform(0.0, 180.0 if shape == "square" else 60.0)
|
|
88
|
+
else:
|
|
89
|
+
theta = float(rotation or 0.0)
|
|
90
|
+
x0, y0, x1, y1 = dom.bounds
|
|
91
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
92
|
+
r = math.hypot(x1 - x0, y1 - y0) / 2 + 2 * s
|
|
93
|
+
off = np.array([rng.uniform(0, s), rng.uniform(0, row_h)])
|
|
94
|
+
ni = int(math.ceil(r / s)) + 2
|
|
95
|
+
nj = int(math.ceil(r / row_h)) + 2
|
|
96
|
+
ii, jj = np.meshgrid(np.arange(-ni, ni + 1), np.arange(-nj, nj + 1))
|
|
97
|
+
gx = ii * s + off[0]
|
|
98
|
+
if shape == "hex":
|
|
99
|
+
gx = gx + (jj % 2) * (s / 2.0)
|
|
100
|
+
gy = jj * row_h + off[1]
|
|
101
|
+
t = math.radians(theta)
|
|
102
|
+
ct, st = math.cos(t), math.sin(t)
|
|
103
|
+
px = cx + gx.ravel() * ct - gy.ravel() * st
|
|
104
|
+
py = cy + gx.ravel() * st + gy.ravel() * ct
|
|
105
|
+
pts = np.column_stack([px, py])
|
|
106
|
+
pts = pts[dom.contains(pts)]
|
|
107
|
+
# serpentine (boustrophedon) visiting order in the grid frame
|
|
108
|
+
lx = (pts[:, 0] - cx) * ct + (pts[:, 1] - cy) * st
|
|
109
|
+
ly = -(pts[:, 0] - cx) * st + (pts[:, 1] - cy) * ct
|
|
110
|
+
row = np.round((ly - off[1]) / row_h).astype(np.int64)
|
|
111
|
+
key = np.where(row % 2 == 0, lx, -lx)
|
|
112
|
+
order_idx = np.lexsort((key, row))
|
|
113
|
+
pts = pts[order_idx]
|
|
114
|
+
m = len(pts)
|
|
115
|
+
density = 1.0 / cell_area
|
|
116
|
+
return Sample(
|
|
117
|
+
xy=pts,
|
|
118
|
+
pi=np.full(m, density),
|
|
119
|
+
weight=np.full(m, cell_area),
|
|
120
|
+
design=f"systematic-{shape}",
|
|
121
|
+
meta={
|
|
122
|
+
"n": n,
|
|
123
|
+
"spacing": s,
|
|
124
|
+
"rotation": theta,
|
|
125
|
+
"area": dom.area,
|
|
126
|
+
"expected_n": dom.area / cell_area,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_CONTINUOUS: Dict[str, Callable[..., Sample]] = {"srs": srs, "bas": bas, "grts": grts, "lpm": lpm}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def stratified(
|
|
135
|
+
strata: Any,
|
|
136
|
+
n: Union[int, Mapping[Hashable, int]],
|
|
137
|
+
allocation: str = "proportional",
|
|
138
|
+
sd: Optional[Mapping[Hashable, float]] = None,
|
|
139
|
+
min_per_stratum: int = 1,
|
|
140
|
+
method: str = "srs",
|
|
141
|
+
seed: SeedLike = None,
|
|
142
|
+
) -> Sample:
|
|
143
|
+
"""Stratified sample over polygons or raster classes.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
strata:
|
|
148
|
+
Mapping ``label -> domain`` (Regions, RasterMasks or GeoJSON), or a
|
|
149
|
+
:class:`~spatialsample.RasterMask` whose distinct class values become
|
|
150
|
+
strata.
|
|
151
|
+
n:
|
|
152
|
+
Total sample size, or an explicit mapping ``label -> n_h``.
|
|
153
|
+
allocation:
|
|
154
|
+
``"proportional"`` (to stratum area), ``"equal"`` or ``"neyman"``
|
|
155
|
+
(proportional to area times ``sd[label]``).
|
|
156
|
+
min_per_stratum:
|
|
157
|
+
Minimum sites per stratum when allocating a total.
|
|
158
|
+
method:
|
|
159
|
+
Within-stratum design: ``"srs"``, ``"bas"``, ``"grts"`` or ``"lpm"``.
|
|
160
|
+
"""
|
|
161
|
+
rng = as_rng(seed)
|
|
162
|
+
if isinstance(strata, RasterMask):
|
|
163
|
+
doms: Dict[Hashable, Domain] = {c: strata.select([c]) for c in strata.class_counts()}
|
|
164
|
+
elif isinstance(strata, Mapping) and strata.get("type") is None:
|
|
165
|
+
doms = {k: as_domain(v) for k, v in strata.items()}
|
|
166
|
+
else:
|
|
167
|
+
raise TypeError("strata must be a mapping label -> domain or a RasterMask")
|
|
168
|
+
if method not in _CONTINUOUS:
|
|
169
|
+
raise ValueError(f"method must be one of {sorted(_CONTINUOUS)}")
|
|
170
|
+
areas = {k: d.area for k, d in doms.items()}
|
|
171
|
+
if isinstance(n, Mapping):
|
|
172
|
+
alloc = {k: int(n.get(k, 0)) for k in doms}
|
|
173
|
+
else:
|
|
174
|
+
alloc = allocate(int(n), areas, allocation, sd=sd, min_per_stratum=min_per_stratum)
|
|
175
|
+
parts: List[Sample] = []
|
|
176
|
+
labels: List[Any] = []
|
|
177
|
+
for k, dom in doms.items():
|
|
178
|
+
nh = alloc[k]
|
|
179
|
+
if nh <= 0:
|
|
180
|
+
continue
|
|
181
|
+
sub = _CONTINUOUS[method](dom, nh, seed=rng)
|
|
182
|
+
parts.append(sub)
|
|
183
|
+
labels.extend([k] * len(sub))
|
|
184
|
+
if not parts:
|
|
185
|
+
raise ValueError("allocation produced an empty sample")
|
|
186
|
+
xy = np.vstack([p.xy for p in parts])
|
|
187
|
+
pi = np.concatenate([p.pi for p in parts])
|
|
188
|
+
return Sample(
|
|
189
|
+
xy=xy,
|
|
190
|
+
pi=pi,
|
|
191
|
+
weight=1.0 / pi,
|
|
192
|
+
design=f"stratified-{method}",
|
|
193
|
+
stratum=np.array(labels, dtype=object),
|
|
194
|
+
meta={
|
|
195
|
+
"n": int(sum(alloc.values())),
|
|
196
|
+
"allocation": alloc,
|
|
197
|
+
"stratum_area": areas,
|
|
198
|
+
"area": float(sum(areas.values())),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def stratified_pixels(
|
|
204
|
+
mask: RasterMask,
|
|
205
|
+
n: Union[int, Mapping[Hashable, int]],
|
|
206
|
+
allocation: str = "proportional",
|
|
207
|
+
sd: Optional[Mapping[Hashable, float]] = None,
|
|
208
|
+
min_per_stratum: int = 1,
|
|
209
|
+
seed: SeedLike = None,
|
|
210
|
+
) -> Sample:
|
|
211
|
+
"""Stratified random sample of *pixels* (without replacement) by map class.
|
|
212
|
+
|
|
213
|
+
This is the design recommended by Olofsson et al. (2014) for accuracy
|
|
214
|
+
assessment and area estimation of land-cover and change maps: strata are
|
|
215
|
+
the map classes, sample units are pixels, and each pixel in stratum ``h``
|
|
216
|
+
has inclusion probability ``n_h / N_h``.
|
|
217
|
+
"""
|
|
218
|
+
rng = as_rng(seed)
|
|
219
|
+
counts = mask.class_counts()
|
|
220
|
+
if isinstance(n, Mapping):
|
|
221
|
+
alloc = {k: int(n.get(k, 0)) for k in counts}
|
|
222
|
+
else:
|
|
223
|
+
alloc = allocate(
|
|
224
|
+
int(n), counts, allocation, sd=sd, min_per_stratum=min_per_stratum, caps=counts
|
|
225
|
+
)
|
|
226
|
+
centres, rows, cols = mask.cell_centers()
|
|
227
|
+
values = mask.data[rows, cols]
|
|
228
|
+
take: List[np.ndarray] = []
|
|
229
|
+
pis: List[np.ndarray] = []
|
|
230
|
+
labs: List[Any] = []
|
|
231
|
+
for k, nh in alloc.items():
|
|
232
|
+
if nh <= 0:
|
|
233
|
+
continue
|
|
234
|
+
idx = np.flatnonzero(values == k)
|
|
235
|
+
if nh > len(idx):
|
|
236
|
+
raise ValueError(f"stratum {k!r} has only {len(idx)} pixels")
|
|
237
|
+
sel = rng.choice(idx, size=nh, replace=False)
|
|
238
|
+
take.append(sel)
|
|
239
|
+
pis.append(np.full(nh, nh / len(idx)))
|
|
240
|
+
labs.extend([k] * nh)
|
|
241
|
+
sel_all = np.concatenate(take)
|
|
242
|
+
pi = np.concatenate(pis)
|
|
243
|
+
return Sample(
|
|
244
|
+
xy=centres[sel_all],
|
|
245
|
+
pi=pi,
|
|
246
|
+
weight=1.0 / pi,
|
|
247
|
+
design="stratified-pixels",
|
|
248
|
+
kind="finite",
|
|
249
|
+
stratum=np.array(labs, dtype=object),
|
|
250
|
+
index=sel_all,
|
|
251
|
+
attrs={"row": rows[sel_all], "col": cols[sel_all]},
|
|
252
|
+
meta={
|
|
253
|
+
"n": int(pi.size),
|
|
254
|
+
"allocation": alloc,
|
|
255
|
+
"stratum_size": counts,
|
|
256
|
+
"population_size": int(sum(counts.values())),
|
|
257
|
+
"cell_area": mask.cell_area,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def poisson_disk(
|
|
263
|
+
domain: Any,
|
|
264
|
+
n: int,
|
|
265
|
+
min_dist: Optional[float] = None,
|
|
266
|
+
max_tries: int = 30000,
|
|
267
|
+
seed: SeedLike = None,
|
|
268
|
+
) -> Sample:
|
|
269
|
+
"""Minimum-distance (simple sequential inhibition) sample.
|
|
270
|
+
|
|
271
|
+
Uniform candidate points are accepted in turn if they lie at least
|
|
272
|
+
``min_dist`` from every point already accepted. The default distance is
|
|
273
|
+
``0.6 * sqrt(area / n)``, comfortably below the random-packing limit
|
|
274
|
+
(about ``0.83 * sqrt(area / n)``). Inclusion densities are reported as
|
|
275
|
+
``n / area``, which is exact only away from the boundary; use GRTS, BAS or
|
|
276
|
+
LPM when design-based inference with exact probabilities matters.
|
|
277
|
+
"""
|
|
278
|
+
dom = as_domain(domain)
|
|
279
|
+
rng = as_rng(seed)
|
|
280
|
+
if n < 1:
|
|
281
|
+
raise ValueError("n must be >= 1")
|
|
282
|
+
d = float(min_dist) if min_dist is not None else 0.6 * math.sqrt(dom.area / n)
|
|
283
|
+
d2 = d * d
|
|
284
|
+
acc = np.empty((n, 2))
|
|
285
|
+
k = 0
|
|
286
|
+
tried = 0
|
|
287
|
+
while k < n and tried < max_tries:
|
|
288
|
+
cand = dom.random_points(min(512, max_tries - tried), rng)
|
|
289
|
+
for c in cand:
|
|
290
|
+
tried += 1
|
|
291
|
+
if k == 0 or np.min(((acc[:k] - c) ** 2).sum(axis=1)) >= d2:
|
|
292
|
+
acc[k] = c
|
|
293
|
+
k += 1
|
|
294
|
+
if k == n:
|
|
295
|
+
break
|
|
296
|
+
if k < n:
|
|
297
|
+
raise ValueError(f"only {k} of {n} points could be placed {d:g} apart; lower min_dist or n")
|
|
298
|
+
density = n / dom.area
|
|
299
|
+
return Sample(
|
|
300
|
+
xy=acc,
|
|
301
|
+
pi=np.full(n, density),
|
|
302
|
+
weight=np.full(n, 1.0 / density),
|
|
303
|
+
design="poisson-disk",
|
|
304
|
+
meta={"n": n, "min_dist": d, "area": dom.area, "approximate_pi": True},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_LAYOUTS: Dict[str, FloatArray] = {
|
|
309
|
+
# US Forest Inventory and Analysis plot: centre plus three subplots at 120 degrees.
|
|
310
|
+
"fia": np.array(
|
|
311
|
+
[[0.0, 0.0]]
|
|
312
|
+
+ [[math.sin(math.radians(a)), math.cos(math.radians(a))] for a in (0.0, 120.0, 240.0)]
|
|
313
|
+
),
|
|
314
|
+
"square": np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) - 0.5,
|
|
315
|
+
"line": np.array([[0.0, k] for k in range(5)]) - np.array([0.0, 2.0]),
|
|
316
|
+
"cross": np.array([[0.0, 0.0], [1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]]),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cluster(
|
|
321
|
+
domain: Any,
|
|
322
|
+
n_clusters: int,
|
|
323
|
+
spacing: float,
|
|
324
|
+
layout: Union[str, Sequence[Sequence[float]]] = "fia",
|
|
325
|
+
rotation: Union[float, str] = 0.0,
|
|
326
|
+
centers: str = "srs",
|
|
327
|
+
seed: SeedLike = None,
|
|
328
|
+
) -> Sample:
|
|
329
|
+
"""Cluster sample: a fixed pattern of sub-plots around each sampled centre.
|
|
330
|
+
|
|
331
|
+
Centres are drawn with ``centers`` (``"srs"``, ``"bas"`` or ``"grts"``) and
|
|
332
|
+
every centre carries the sub-plot ``layout`` scaled by ``spacing``:
|
|
333
|
+
``"fia"`` (centre + 3 at 120 degrees), ``"square"`` (2 x 2), ``"line"``
|
|
334
|
+
(5 in a row), ``"cross"`` (5) or an explicit list of unit offsets.
|
|
335
|
+
Sub-plots that fall outside the domain are dropped. Each sub-plot location
|
|
336
|
+
has inclusion density ``n_clusters / area`` (exact away from the edge);
|
|
337
|
+
analyse cluster means, not individual sub-plots, for variance estimation.
|
|
338
|
+
"""
|
|
339
|
+
dom = as_domain(domain)
|
|
340
|
+
rng = as_rng(seed)
|
|
341
|
+
offs = _LAYOUTS[layout] if isinstance(layout, str) else np.asarray(layout, dtype=float)
|
|
342
|
+
centre_design = _CONTINUOUS.get(centers)
|
|
343
|
+
if centre_design is None or centers == "lpm":
|
|
344
|
+
raise ValueError("centers must be 'srs', 'bas' or 'grts'")
|
|
345
|
+
base = centre_design(dom, n_clusters, seed=rng)
|
|
346
|
+
cs = base.xy
|
|
347
|
+
m = len(offs)
|
|
348
|
+
if rotation == "random":
|
|
349
|
+
ang = rng.uniform(0, 2 * math.pi, len(cs))
|
|
350
|
+
else:
|
|
351
|
+
ang = np.full(len(cs), math.radians(float(rotation)))
|
|
352
|
+
ca, sa = np.cos(ang)[:, None], np.sin(ang)[:, None]
|
|
353
|
+
ox = offs[None, :, 0] * spacing
|
|
354
|
+
oy = offs[None, :, 1] * spacing
|
|
355
|
+
px = cs[:, 0:1] + ox * ca - oy * sa
|
|
356
|
+
py = cs[:, 1:2] + ox * sa + oy * ca
|
|
357
|
+
xy = np.column_stack([px.ravel(), py.ravel()])
|
|
358
|
+
cid = np.repeat(np.arange(len(cs)), m)
|
|
359
|
+
sub = np.tile(np.arange(m), len(cs))
|
|
360
|
+
keep = dom.contains(xy)
|
|
361
|
+
density = n_clusters / dom.area
|
|
362
|
+
k = int(keep.sum())
|
|
363
|
+
return Sample(
|
|
364
|
+
xy=xy[keep],
|
|
365
|
+
pi=np.full(k, density),
|
|
366
|
+
weight=np.full(k, 1.0 / density),
|
|
367
|
+
design=f"cluster-{centers}",
|
|
368
|
+
attrs={"cluster": cid[keep], "subplot": sub[keep]},
|
|
369
|
+
meta={"n_clusters": n_clusters, "subplots": m, "spacing": spacing, "area": dom.area},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def two_stage(
|
|
374
|
+
psus: Mapping[Hashable, Any],
|
|
375
|
+
n_psu: int,
|
|
376
|
+
m_per_psu: int,
|
|
377
|
+
pps: bool = True,
|
|
378
|
+
seed: SeedLike = None,
|
|
379
|
+
) -> Sample:
|
|
380
|
+
"""Two-stage sample: primary units (polygons), then random sites inside them.
|
|
381
|
+
|
|
382
|
+
First stage: ``n_psu`` primary sampling units (PSUs, e.g. villages, census
|
|
383
|
+
tracts, forest compartments) are drawn without replacement, with
|
|
384
|
+
probability proportional to area (``pps=True``, randomised systematic
|
|
385
|
+
PPS) or with equal probability. Second stage: ``m_per_psu`` uniform sites
|
|
386
|
+
in each selected PSU. With PPS and a fixed ``m_per_psu`` the design is
|
|
387
|
+
self-weighting (every site represents the same area).
|
|
388
|
+
"""
|
|
389
|
+
rng = as_rng(seed)
|
|
390
|
+
doms = {k: as_domain(v) for k, v in psus.items()}
|
|
391
|
+
keys = list(doms)
|
|
392
|
+
if not 1 <= n_psu <= len(keys):
|
|
393
|
+
raise ValueError("n_psu must be between 1 and the number of PSUs")
|
|
394
|
+
areas = np.array([doms[k].area for k in keys])
|
|
395
|
+
sizes = areas if pps else np.ones(len(keys))
|
|
396
|
+
pi1 = inclusion_probabilities(sizes, n_psu)
|
|
397
|
+
perm = rng.permutation(len(keys))
|
|
398
|
+
chosen = perm[systematic_pps(pi1[perm], rng)]
|
|
399
|
+
xy_parts: List[FloatArray] = []
|
|
400
|
+
dens: List[np.ndarray] = []
|
|
401
|
+
lab: List[Any] = []
|
|
402
|
+
for c in chosen:
|
|
403
|
+
k = keys[c]
|
|
404
|
+
pts = doms[k].random_points(m_per_psu, rng)
|
|
405
|
+
xy_parts.append(pts)
|
|
406
|
+
dens.append(np.full(m_per_psu, pi1[c] * m_per_psu / areas[c]))
|
|
407
|
+
lab.extend([k] * m_per_psu)
|
|
408
|
+
pi = np.concatenate(dens)
|
|
409
|
+
return Sample(
|
|
410
|
+
xy=np.vstack(xy_parts),
|
|
411
|
+
pi=pi,
|
|
412
|
+
weight=1.0 / pi,
|
|
413
|
+
design="two-stage",
|
|
414
|
+
attrs={
|
|
415
|
+
"psu": np.array(lab, dtype=object),
|
|
416
|
+
"psu_pi": np.repeat(pi1[chosen], m_per_psu),
|
|
417
|
+
},
|
|
418
|
+
meta={"n_psu": n_psu, "m_per_psu": m_per_psu, "pps": pps, "area": float(areas.sum())},
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _as_lines(lines: Any) -> List[FloatArray]:
|
|
423
|
+
if isinstance(lines, Mapping) or hasattr(lines, "__geo_interface__"):
|
|
424
|
+
obj = lines if isinstance(lines, Mapping) else lines.__geo_interface__
|
|
425
|
+
out: List[FloatArray] = []
|
|
426
|
+
_collect_lines(obj, out)
|
|
427
|
+
if not out:
|
|
428
|
+
raise ValueError("no LineString geometry found")
|
|
429
|
+
return out
|
|
430
|
+
arr = [np.asarray(ln, dtype=float)[:, :2] for ln in lines]
|
|
431
|
+
for a in arr:
|
|
432
|
+
if a.ndim != 2 or len(a) < 2:
|
|
433
|
+
raise ValueError("each line needs at least two vertices")
|
|
434
|
+
return arr
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _collect_lines(obj: Mapping[str, Any], out: List[FloatArray]) -> None:
|
|
438
|
+
t = obj.get("type")
|
|
439
|
+
if t == "FeatureCollection":
|
|
440
|
+
for f in obj.get("features", []):
|
|
441
|
+
_collect_lines(f, out)
|
|
442
|
+
elif t == "Feature" and obj.get("geometry"):
|
|
443
|
+
_collect_lines(obj["geometry"], out)
|
|
444
|
+
elif t == "GeometryCollection":
|
|
445
|
+
for g in obj.get("geometries", []):
|
|
446
|
+
_collect_lines(g, out)
|
|
447
|
+
elif t == "LineString":
|
|
448
|
+
out.append(np.asarray(obj["coordinates"], dtype=float)[:, :2])
|
|
449
|
+
elif t == "MultiLineString":
|
|
450
|
+
out.extend(np.asarray(c, dtype=float)[:, :2] for c in obj["coordinates"])
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def points_along_lines(
|
|
454
|
+
lines: Any,
|
|
455
|
+
n: Optional[int] = None,
|
|
456
|
+
spacing: Optional[float] = None,
|
|
457
|
+
seed: SeedLike = None,
|
|
458
|
+
) -> Sample:
|
|
459
|
+
"""Systematic points along lines (roads, rivers, hedgerows, transects).
|
|
460
|
+
|
|
461
|
+
The lines are chained end to end; points are placed every ``spacing``
|
|
462
|
+
length units from a uniformly random start in ``[0, spacing)``, so each
|
|
463
|
+
location along the network has the same inclusion density ``1/spacing``
|
|
464
|
+
per unit length. Give ``n`` to derive ``spacing = total_length / n``.
|
|
465
|
+
"""
|
|
466
|
+
rng = as_rng(seed)
|
|
467
|
+
segs = _as_lines(lines)
|
|
468
|
+
lens = [np.hypot(*np.diff(s, axis=0).T) for s in segs]
|
|
469
|
+
totals = np.array([float(L.sum()) for L in lens])
|
|
470
|
+
total = float(totals.sum())
|
|
471
|
+
if total <= 0:
|
|
472
|
+
raise ValueError("lines have zero length")
|
|
473
|
+
if spacing is None:
|
|
474
|
+
if n is None or n < 1:
|
|
475
|
+
raise ValueError("give n >= 1 or spacing")
|
|
476
|
+
spacing = total / n
|
|
477
|
+
s = float(spacing)
|
|
478
|
+
pos = rng.uniform(0, s) + s * np.arange(int(math.floor(total / s)) + 1)
|
|
479
|
+
pos = pos[pos < total]
|
|
480
|
+
starts = np.concatenate([[0.0], np.cumsum(totals)[:-1]])
|
|
481
|
+
line_id = np.searchsorted(np.cumsum(totals), pos, side="right")
|
|
482
|
+
line_id = np.minimum(line_id, len(segs) - 1)
|
|
483
|
+
xy = np.empty((len(pos), 2))
|
|
484
|
+
chain = pos - starts[line_id]
|
|
485
|
+
for k in range(len(segs)):
|
|
486
|
+
sel = line_id == k
|
|
487
|
+
if not sel.any():
|
|
488
|
+
continue
|
|
489
|
+
cum = np.concatenate([[0.0], np.cumsum(lens[k])])
|
|
490
|
+
xy[sel, 0] = np.interp(chain[sel], cum, segs[k][:, 0])
|
|
491
|
+
xy[sel, 1] = np.interp(chain[sel], cum, segs[k][:, 1])
|
|
492
|
+
m = len(pos)
|
|
493
|
+
return Sample(
|
|
494
|
+
xy=xy,
|
|
495
|
+
pi=np.full(m, 1.0 / s),
|
|
496
|
+
weight=np.full(m, s),
|
|
497
|
+
design="line-systematic",
|
|
498
|
+
attrs={"line": line_id, "chainage": chain},
|
|
499
|
+
meta={"spacing": s, "total_length": total},
|
|
500
|
+
lines=segs,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def random_transects(
|
|
505
|
+
domain: Any,
|
|
506
|
+
n: int,
|
|
507
|
+
length: float,
|
|
508
|
+
bearing: Optional[float] = None,
|
|
509
|
+
spacing: Optional[float] = None,
|
|
510
|
+
seed: SeedLike = None,
|
|
511
|
+
) -> Sample:
|
|
512
|
+
"""Random straight transects centred on uniform random points.
|
|
513
|
+
|
|
514
|
+
Each transect has the given ``length`` and a uniformly random bearing (or
|
|
515
|
+
a fixed ``bearing`` in degrees clockwise from north). With ``spacing`` the
|
|
516
|
+
sample contains observation points every ``spacing`` along each transect
|
|
517
|
+
(points outside the domain are dropped); otherwise it contains the centres.
|
|
518
|
+
The transect lines are kept in ``Sample.lines``.
|
|
519
|
+
"""
|
|
520
|
+
dom = as_domain(domain)
|
|
521
|
+
rng = as_rng(seed)
|
|
522
|
+
if n < 1 or length <= 0:
|
|
523
|
+
raise ValueError("n must be >= 1 and length > 0")
|
|
524
|
+
centres = dom.random_points(n, rng)
|
|
525
|
+
if bearing is None:
|
|
526
|
+
az = rng.uniform(0.0, 180.0, n)
|
|
527
|
+
else:
|
|
528
|
+
az = np.full(n, float(bearing))
|
|
529
|
+
t = np.radians(az)
|
|
530
|
+
dx, dy = np.sin(t) * length / 2, np.cos(t) * length / 2
|
|
531
|
+
lines = [
|
|
532
|
+
np.array([[c[0] - a, c[1] - b], [c[0] + a, c[1] + b]]) for c, a, b in zip(centres, dx, dy)
|
|
533
|
+
]
|
|
534
|
+
if spacing is None:
|
|
535
|
+
xy = centres
|
|
536
|
+
tid = np.arange(n)
|
|
537
|
+
chain = np.full(n, length / 2)
|
|
538
|
+
else:
|
|
539
|
+
offs = np.arange(0.0, length + 1e-9, float(spacing))
|
|
540
|
+
frac = offs / length
|
|
541
|
+
xy_l, tid_l, ch_l = [], [], []
|
|
542
|
+
for k, ln in enumerate(lines):
|
|
543
|
+
p = ln[0] + frac[:, None] * (ln[1] - ln[0])
|
|
544
|
+
xy_l.append(p)
|
|
545
|
+
tid_l.append(np.full(len(p), k))
|
|
546
|
+
ch_l.append(offs)
|
|
547
|
+
xy = np.vstack(xy_l)
|
|
548
|
+
tid = np.concatenate(tid_l)
|
|
549
|
+
chain = np.concatenate(ch_l)
|
|
550
|
+
keep = dom.contains(xy)
|
|
551
|
+
xy, tid, chain = xy[keep], tid[keep], chain[keep]
|
|
552
|
+
density = n / dom.area
|
|
553
|
+
m = len(xy)
|
|
554
|
+
return Sample(
|
|
555
|
+
xy=xy,
|
|
556
|
+
pi=np.full(m, density),
|
|
557
|
+
weight=np.full(m, 1.0 / density),
|
|
558
|
+
design="random-transects",
|
|
559
|
+
attrs={"transect": tid, "chainage": chain, "bearing": az[tid]},
|
|
560
|
+
meta={"n": n, "length": length, "area": dom.area},
|
|
561
|
+
lines=lines,
|
|
562
|
+
)
|