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,89 @@
1
+ """spatialsample: design-based spatial sampling in pure NumPy.
2
+
3
+ Spatially balanced designs (GRTS, BAS, LPM), classical designs (simple
4
+ random, systematic, stratified, cluster, two-stage, transects, Poisson-disk),
5
+ inclusion probabilities and design weights, Horvitz-Thompson estimation with
6
+ local-neighbourhood variance, the Voronoi spatial-balance metric and map
7
+ accuracy assessment following Olofsson et al. (2014).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ from .accuracy import AccuracyAssessment, allocate_accuracy_sample, olofsson, sample_size
15
+ from .balance import BalanceResult, compare_designs, spatial_balance, voronoi_balance
16
+ from .bas import bas, halton
17
+ from .designs import (
18
+ cluster,
19
+ points_along_lines,
20
+ poisson_disk,
21
+ random_transects,
22
+ srs,
23
+ stratified,
24
+ stratified_pixels,
25
+ systematic,
26
+ two_stage,
27
+ )
28
+ from .estimators import (
29
+ Estimate,
30
+ estimate,
31
+ ht_mean,
32
+ ht_total,
33
+ local_variance,
34
+ local_weights,
35
+ srs_variance,
36
+ )
37
+ from .geometry import Polygon, Region
38
+ from .grts import grts, grts_order, reverse_hierarchical_order
39
+ from .io import read_lines, read_matrix_csv, read_points, read_region, read_strata
40
+ from .lpm import lpm, lpm2_select
41
+ from .probabilities import allocate, inclusion_probabilities
42
+ from .raster import RasterMask
43
+ from .sample import Sample
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ "AccuracyAssessment",
48
+ "BalanceResult",
49
+ "Estimate",
50
+ "Polygon",
51
+ "RasterMask",
52
+ "Region",
53
+ "Sample",
54
+ "allocate",
55
+ "allocate_accuracy_sample",
56
+ "bas",
57
+ "cluster",
58
+ "compare_designs",
59
+ "estimate",
60
+ "grts",
61
+ "grts_order",
62
+ "halton",
63
+ "ht_mean",
64
+ "ht_total",
65
+ "inclusion_probabilities",
66
+ "local_variance",
67
+ "local_weights",
68
+ "lpm",
69
+ "lpm2_select",
70
+ "olofsson",
71
+ "points_along_lines",
72
+ "poisson_disk",
73
+ "random_transects",
74
+ "read_lines",
75
+ "read_matrix_csv",
76
+ "read_points",
77
+ "read_region",
78
+ "read_strata",
79
+ "reverse_hierarchical_order",
80
+ "sample_size",
81
+ "spatial_balance",
82
+ "srs",
83
+ "srs_variance",
84
+ "stratified",
85
+ "stratified_pixels",
86
+ "systematic",
87
+ "two_stage",
88
+ "voronoi_balance",
89
+ ]
@@ -0,0 +1,91 @@
1
+ """Internal helpers: resolve inputs into a domain or a finite frame, build grid frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, Callable, Mapping, Optional, Tuple, Union
7
+
8
+ import numpy as np
9
+
10
+ from ._types import Domain, FloatArray
11
+ from .geometry import Region
12
+ from .raster import RasterMask
13
+
14
+ FrameInput = Union[Domain, FloatArray, Mapping[str, Any]]
15
+ ProbLike = Optional[Union[FloatArray, Callable[[FloatArray], Any]]]
16
+
17
+
18
+ def as_domain(obj: Any) -> Domain:
19
+ """Coerce a Region, RasterMask, GeoJSON mapping or ``__geo_interface__`` object."""
20
+ if isinstance(obj, (Region, RasterMask)):
21
+ return obj
22
+ if isinstance(obj, Mapping) or hasattr(obj, "__geo_interface__"):
23
+ return Region.from_geojson(obj)
24
+ if all(hasattr(obj, a) for a in ("area", "bounds", "contains", "random_points")):
25
+ return obj # duck-typed custom domain
26
+ raise TypeError(
27
+ "expected a Region, RasterMask, GeoJSON mapping or object with __geo_interface__"
28
+ )
29
+
30
+
31
+ def resolve(obj: Any) -> Tuple[Optional[Domain], Optional[FloatArray]]:
32
+ """Return ``(domain, None)`` for areal input or ``(None, points)`` for a finite frame."""
33
+ if isinstance(obj, (Region, RasterMask, Mapping)) or hasattr(obj, "__geo_interface__"):
34
+ return as_domain(obj), None
35
+ if all(hasattr(obj, a) for a in ("area", "bounds", "contains", "random_points")):
36
+ return obj, None
37
+ pts = np.asarray(obj, dtype=float)
38
+ if pts.ndim != 2 or pts.shape[1] < 2:
39
+ raise ValueError("a finite frame must be an (N, 2) array of coordinates")
40
+ return None, pts[:, :2]
41
+
42
+
43
+ def grid_frame(
44
+ domain: Domain, size: int, rng: np.random.Generator, min_points: int = 1
45
+ ) -> Tuple[FloatArray, float]:
46
+ """Square-grid frame of roughly ``size`` cell centres inside ``domain``.
47
+
48
+ The grid has a uniformly random offset, so every location in the domain is
49
+ equally likely to lie in any cell. Returns ``(points, spacing)``.
50
+ """
51
+ h = math.sqrt(domain.area / max(size, 1))
52
+ x0, y0, x1, y1 = domain.bounds
53
+ for _ in range(12):
54
+ off = rng.uniform(0.0, h, 2)
55
+ xs = x0 - off[0] + h * (np.arange(int(math.ceil((x1 - x0) / h)) + 2) + 0.5) - h
56
+ ys = y0 - off[1] + h * (np.arange(int(math.ceil((y1 - y0) / h)) + 2) + 0.5) - h
57
+ gx, gy = np.meshgrid(xs, ys)
58
+ pts = np.column_stack([gx.ravel(), gy.ravel()])
59
+ pts = pts[domain.contains(pts)]
60
+ if len(pts) >= max(min_points, size // 2):
61
+ return pts, h
62
+ h /= math.sqrt(2.0)
63
+ raise ValueError("could not build a grid frame; the domain may be too thin")
64
+
65
+
66
+ def jitter_in_cells(
67
+ centres: FloatArray, h: float, domain: Domain, rng: np.random.Generator, tries: int = 50
68
+ ) -> FloatArray:
69
+ """Move each grid centre to a uniform location in its cell that lies in the domain."""
70
+ out = centres.copy()
71
+ todo = np.arange(len(centres))
72
+ for _ in range(tries):
73
+ if len(todo) == 0:
74
+ break
75
+ cand = centres[todo] + rng.uniform(-h / 2, h / 2, (len(todo), 2))
76
+ ok = domain.contains(cand)
77
+ out[todo[ok]] = cand[ok]
78
+ todo = todo[~ok]
79
+ return out
80
+
81
+
82
+ def frame_sizes(prob: ProbLike, xy: FloatArray) -> FloatArray:
83
+ if prob is None:
84
+ return np.ones(len(xy))
85
+ if callable(prob):
86
+ sizes = np.asarray(prob(xy), dtype=float).ravel()
87
+ else:
88
+ sizes = np.asarray(prob, dtype=float).ravel()
89
+ if len(sizes) != len(xy):
90
+ raise ValueError("prob must give one value per frame unit")
91
+ return sizes
@@ -0,0 +1,36 @@
1
+ """Shared type aliases and small helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, Tuple, Union
6
+
7
+ import numpy as np
8
+
9
+ try: # Protocol is in typing from 3.8
10
+ from typing import Protocol
11
+ except ImportError: # pragma: no cover
12
+ from typing_extensions import Protocol # type: ignore[assignment]
13
+
14
+ FloatArray = np.ndarray
15
+ SeedLike = Optional[Union[int, np.random.Generator, np.random.SeedSequence]]
16
+
17
+
18
+ def as_rng(seed: SeedLike) -> np.random.Generator:
19
+ """Return a NumPy ``Generator`` from an int, a generator or ``None``."""
20
+ if isinstance(seed, np.random.Generator):
21
+ return seed
22
+ return np.random.default_rng(seed)
23
+
24
+
25
+ class Domain(Protocol):
26
+ """Anything that can be sampled continuously: a polygon region or a raster mask."""
27
+
28
+ @property
29
+ def area(self) -> float: ...
30
+
31
+ @property
32
+ def bounds(self) -> Tuple[float, float, float, float]: ...
33
+
34
+ def contains(self, xy: Any) -> np.ndarray: ...
35
+
36
+ def random_points(self, n: int, seed: SeedLike = None) -> FloatArray: ...
@@ -0,0 +1,232 @@
1
+ """Map accuracy assessment and area estimation, Olofsson et al. (2014).
2
+
3
+ Good-practice estimators for a stratified random sample in which the strata
4
+ are the map classes. Given the error matrix of *sample counts* (rows = map
5
+ class, columns = reference class) and the mapped area of each class, the
6
+ functions return unbiased (stratified) estimates of overall, user's and
7
+ producer's accuracy and of the area of each reference class, all with
8
+ standard errors and confidence intervals. :func:`sample_size` implements
9
+ equation 13 of the paper (Cochran 1977, eq. 5.25) for a target standard error
10
+ of overall accuracy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import dataclass, field
17
+ from statistics import NormalDist
18
+ from typing import Any, Dict, List, Optional, Sequence
19
+
20
+ import numpy as np
21
+
22
+ from .probabilities import allocate
23
+
24
+ __all__ = ["olofsson", "AccuracyAssessment", "sample_size", "allocate_accuracy_sample"]
25
+
26
+
27
+ @dataclass
28
+ class AccuracyAssessment:
29
+ """Stratified estimates from an error matrix (Olofsson et al. 2014)."""
30
+
31
+ labels: List[str]
32
+ counts: np.ndarray
33
+ mapped_area: np.ndarray
34
+ proportions: np.ndarray
35
+ overall: float
36
+ overall_se: float
37
+ users: np.ndarray
38
+ users_se: np.ndarray
39
+ producers: np.ndarray
40
+ producers_se: np.ndarray
41
+ area_proportion: np.ndarray
42
+ area_proportion_se: np.ndarray
43
+ area: np.ndarray
44
+ area_se: np.ndarray
45
+ conf: float = 0.95
46
+ z: float = field(default=1.959963984540054)
47
+
48
+ @property
49
+ def area_ci(self) -> np.ndarray:
50
+ """Half-width of the confidence interval for each class area."""
51
+ return self.z * self.area_se
52
+
53
+ def ci(self, se: Any) -> Any:
54
+ return self.z * np.asarray(se)
55
+
56
+ def table(self) -> List[Dict[str, Any]]:
57
+ """One row per class with all estimates and CI half-widths."""
58
+ rows = []
59
+ for i, lab in enumerate(self.labels):
60
+ rows.append(
61
+ {
62
+ "class": lab,
63
+ "mapped_area": float(self.mapped_area[i]),
64
+ "area": float(self.area[i]),
65
+ "area_se": float(self.area_se[i]),
66
+ "area_ci": float(self.z * self.area_se[i]),
67
+ "users": float(self.users[i]),
68
+ "users_ci": float(self.z * self.users_se[i]),
69
+ "producers": float(self.producers[i]),
70
+ "producers_ci": float(self.z * self.producers_se[i]),
71
+ }
72
+ )
73
+ return rows
74
+
75
+ def summary(self, digits: int = 3) -> str:
76
+ """Plain-text report."""
77
+ pct = int(round(self.conf * 100))
78
+ head = (
79
+ f"{'class':<20}{'mapped area':>14}{'est. area':>14}{'+/- ' + str(pct) + '% CI':>14}"
80
+ f"{'user':>9}{'+/-':>8}{'producer':>10}{'+/-':>8}"
81
+ )
82
+ lines = [head, "-" * len(head)]
83
+ for r in self.table():
84
+ lines.append(
85
+ f"{str(r['class']):<20}{r['mapped_area']:>14,.{0}f}{r['area']:>14,.{0}f}"
86
+ f"{r['area_ci']:>14,.{0}f}{r['users']:>9.{digits}f}{r['users_ci']:>8.{digits}f}"
87
+ f"{r['producers']:>10.{digits}f}{r['producers_ci']:>8.{digits}f}"
88
+ )
89
+ lines.append(
90
+ f"overall accuracy {self.overall:.{digits}f} +/- {self.z * self.overall_se:.{digits}f}"
91
+ f" ({pct}% CI), n = {int(self.counts.sum())}"
92
+ )
93
+ return "\n".join(lines)
94
+
95
+ def as_dict(self) -> Dict[str, Any]:
96
+ return {
97
+ "labels": list(self.labels),
98
+ "conf": self.conf,
99
+ "overall": self.overall,
100
+ "overall_se": self.overall_se,
101
+ "classes": self.table(),
102
+ "proportions": self.proportions.tolist(),
103
+ }
104
+
105
+
106
+ def olofsson(
107
+ matrix: Any,
108
+ mapped_area: Sequence[float],
109
+ labels: Optional[Sequence[str]] = None,
110
+ conf: float = 0.95,
111
+ ) -> AccuracyAssessment:
112
+ """Accuracy and area estimates from an error matrix of sample counts.
113
+
114
+ Parameters
115
+ ----------
116
+ matrix:
117
+ ``(q, q)`` sample counts ``n_ij``: rows are map classes (strata),
118
+ columns are reference classes, in the same class order.
119
+ mapped_area:
120
+ Mapped area (or pixel count) of each map class; estimated areas are
121
+ returned in the same unit.
122
+ labels:
123
+ Class names (default ``"1"`` ... ``"q"``).
124
+ conf:
125
+ Confidence level for intervals (default 0.95, ``z = 1.96``).
126
+ """
127
+ n = np.asarray(matrix, dtype=float)
128
+ if n.ndim != 2 or n.shape[0] != n.shape[1]:
129
+ raise ValueError("the error matrix must be square")
130
+ q = n.shape[0]
131
+ am = np.asarray(mapped_area, dtype=float).ravel()
132
+ if len(am) != q:
133
+ raise ValueError("need one mapped area per map class")
134
+ if np.any(n < 0) or np.any(am < 0):
135
+ raise ValueError("counts and areas must be non-negative")
136
+ ni = n.sum(axis=1)
137
+ if np.any(ni < 2):
138
+ raise ValueError("every map class needs at least two sample units")
139
+ a_tot = am.sum()
140
+ w = am / a_tot
141
+ frac = n / ni[:, None] # n_ij / n_i.
142
+ p = w[:, None] * frac # estimated cell proportions
143
+ diag = np.diag(p)
144
+ overall = float(diag.sum())
145
+ users = np.diag(frac)
146
+ users_var = users * (1 - users) / (ni - 1)
147
+ overall_var = float(np.sum(w**2 * users_var))
148
+ p_col = p.sum(axis=0)
149
+ with np.errstate(divide="ignore", invalid="ignore"):
150
+ producers = np.where(p_col > 0, diag / p_col, np.nan)
151
+ # Producer's accuracy variance (Olofsson et al. 2014, eq. 7; Card 1982)
152
+ n_col_hat = (am[:, None] / ni[:, None] * n).sum(axis=0) # estimated reference totals
153
+ cell_var = frac * (1 - frac) / (ni[:, None] - 1)
154
+ prod_var = np.full(q, np.nan)
155
+ for j in range(q):
156
+ if n_col_hat[j] <= 0:
157
+ continue
158
+ other = sum(am[i] ** 2 * cell_var[i, j] for i in range(q) if i != j)
159
+ term = am[j] ** 2 * (1 - producers[j]) ** 2 * users_var[j] + producers[j] ** 2 * other
160
+ prod_var[j] = term / n_col_hat[j] ** 2
161
+ area_prop = p_col
162
+ area_prop_var = np.sum(w[:, None] ** 2 * cell_var, axis=0) # eq. 10
163
+ z = NormalDist().inv_cdf(0.5 + conf / 2)
164
+ return AccuracyAssessment(
165
+ labels=[str(s) for s in (labels or [str(i + 1) for i in range(q)])],
166
+ counts=n,
167
+ mapped_area=am,
168
+ proportions=p,
169
+ overall=overall,
170
+ overall_se=math.sqrt(overall_var),
171
+ users=users,
172
+ users_se=np.sqrt(users_var),
173
+ producers=producers,
174
+ producers_se=np.sqrt(prod_var),
175
+ area_proportion=area_prop,
176
+ area_proportion_se=np.sqrt(area_prop_var),
177
+ area=area_prop * a_tot,
178
+ area_se=np.sqrt(area_prop_var) * a_tot,
179
+ conf=conf,
180
+ z=z,
181
+ )
182
+
183
+
184
+ def sample_size(
185
+ mapped_area: Sequence[float], expected_users: Sequence[float], target_se: float = 0.01
186
+ ) -> int:
187
+ """Total sample size for a target standard error of overall accuracy.
188
+
189
+ ``n = (sum_i W_i S_i / S(O))^2`` with ``S_i = sqrt(U_i (1 - U_i))``, where
190
+ ``W_i`` are mapped area proportions and ``U_i`` conjectured user's
191
+ accuracies (Olofsson et al. 2014, eq. 13). Rounded up.
192
+ """
193
+ am = np.asarray(mapped_area, dtype=float)
194
+ u = np.asarray(expected_users, dtype=float)
195
+ if am.shape != u.shape:
196
+ raise ValueError("need one expected user's accuracy per class")
197
+ if target_se <= 0:
198
+ raise ValueError("target_se must be positive")
199
+ w = am / am.sum()
200
+ s = np.sqrt(u * (1 - u))
201
+ return int(math.ceil((np.sum(w * s) / target_se) ** 2 - 1e-9))
202
+
203
+
204
+ def allocate_accuracy_sample(
205
+ n: int,
206
+ mapped_area: Sequence[float],
207
+ method: str = "proportional",
208
+ min_per_class: int = 0,
209
+ rare: Optional[Dict[int, int]] = None,
210
+ ) -> List[int]:
211
+ """Allocate ``n`` among map classes.
212
+
213
+ ``method`` is ``"proportional"`` or ``"equal"``. ``rare`` fixes the size of
214
+ selected classes (index -> n), as Olofsson et al. (2014) recommend for
215
+ small change classes (e.g. 50-100 units each), and the remainder is
216
+ allocated proportionally among the other classes.
217
+ """
218
+ am = [float(a) for a in mapped_area]
219
+ if not rare:
220
+ return list(allocate(n, am, method, min_per_stratum=min_per_class))
221
+ fixed = {int(k): int(v) for k, v in rare.items()}
222
+ rest_idx = [i for i in range(len(am)) if i not in fixed]
223
+ left = n - sum(fixed.values())
224
+ if left < 0:
225
+ raise ValueError("fixed class sizes exceed n")
226
+ rest = allocate(left, [am[i] for i in rest_idx], method, min_per_stratum=min_per_class)
227
+ out = [0] * len(am)
228
+ for i, v in fixed.items():
229
+ out[i] = v
230
+ for i, v in zip(rest_idx, rest):
231
+ out[i] = int(v)
232
+ return out
@@ -0,0 +1,144 @@
1
+ """Voronoi spatial-balance metric of Stevens & Olsen (2004).
2
+
3
+ For each sample site ``i`` let ``v_i`` be the total inclusion probability of
4
+ the frame units in the site's Voronoi polygon (the units nearer to ``i`` than
5
+ to any other site). In a perfectly balanced sample every ``v_i`` equals one.
6
+ The metric is the variance ``mean((v_i - 1)^2)``; smaller is better. Simple
7
+ random samples typically score around 0.3; GRTS, BAS and LPM designs are
8
+ markedly lower.
9
+
10
+ Voronoi polygons are evaluated on a dense frame (a fine grid for continuous
11
+ domains), so no polygon clipping is required.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any, Dict, Mapping, Optional
18
+
19
+ import numpy as np
20
+
21
+ from ._frame import as_domain, grid_frame
22
+ from ._types import SeedLike, as_rng
23
+ from .probabilities import nearest_index
24
+ from .sample import Sample
25
+
26
+ __all__ = ["voronoi_balance", "spatial_balance", "BalanceResult", "compare_designs"]
27
+
28
+
29
+ @dataclass
30
+ class BalanceResult:
31
+ """Voronoi balance of a sample."""
32
+
33
+ value: float
34
+ v: np.ndarray
35
+ n: int
36
+
37
+ @property
38
+ def max_deviation(self) -> float:
39
+ return float(np.max(np.abs(self.v - 1.0))) if self.n else float("nan")
40
+
41
+
42
+ def voronoi_balance(sample_xy: Any, frame_xy: Any, frame_pi: Optional[Any] = None) -> BalanceResult:
43
+ """Voronoi balance of sites ``sample_xy`` against a finite frame.
44
+
45
+ ``frame_pi`` are the frame units' inclusion probabilities; by default they
46
+ are equal and sum to the sample size.
47
+ """
48
+ s = np.asarray(sample_xy, dtype=float).reshape(-1, 2)
49
+ f = np.asarray(frame_xy, dtype=float).reshape(-1, 2)
50
+ n = len(s)
51
+ if n == 0:
52
+ raise ValueError("empty sample")
53
+ if frame_pi is None:
54
+ pi = np.full(len(f), n / len(f))
55
+ else:
56
+ pi = np.asarray(frame_pi, dtype=float).ravel()
57
+ if len(pi) != len(f):
58
+ raise ValueError("frame_pi must have one value per frame point")
59
+ pi = pi * (n / pi.sum())
60
+ owner = nearest_index(f, s)
61
+ v = np.bincount(owner, weights=pi, minlength=n)
62
+ return BalanceResult(value=float(np.mean((v - 1.0) ** 2)), v=v, n=n)
63
+
64
+
65
+ def spatial_balance(
66
+ sample: Any,
67
+ domain: Any = None,
68
+ frame_xy: Any = None,
69
+ frame_pi: Any = None,
70
+ frame_size: int = 20000,
71
+ base_only: bool = True,
72
+ seed: SeedLike = 0,
73
+ ) -> BalanceResult:
74
+ """Voronoi balance of a :class:`Sample` (or ``(n, 2)`` array).
75
+
76
+ Give either a finite frame (``frame_xy``, optional ``frame_pi``) or a
77
+ ``domain``, which is discretised into about ``frame_size`` grid points
78
+ with equal inclusion probability.
79
+ """
80
+ if isinstance(sample, Sample):
81
+ smp = sample.base() if base_only else sample
82
+ xy = smp.xy
83
+ else:
84
+ xy = np.asarray(sample, dtype=float).reshape(-1, 2)
85
+ if frame_xy is None:
86
+ if domain is None:
87
+ raise ValueError("give a domain or frame_xy")
88
+ frame_xy, _ = grid_frame(as_domain(domain), frame_size, as_rng(seed))
89
+ return voronoi_balance(xy, frame_xy, frame_pi)
90
+
91
+
92
+ def compare_designs(
93
+ domain: Any,
94
+ n: int,
95
+ reps: int = 20,
96
+ designs: Optional[Mapping[str, Any]] = None,
97
+ frame_size: int = 20000,
98
+ seed: SeedLike = 0,
99
+ ) -> Dict[str, Dict[str, float]]:
100
+ """Mean and standard deviation of Voronoi balance over repeated draws.
101
+
102
+ ``designs`` maps a label to a callable ``f(domain, n, seed) -> Sample``;
103
+ by default SRS, square and hex systematic grids, BAS, GRTS, LPM and
104
+ Poisson-disk are compared.
105
+ """
106
+ from .bas import bas
107
+ from .designs import poisson_disk, srs, systematic
108
+ from .grts import grts
109
+ from .lpm import lpm
110
+
111
+ dom = as_domain(domain)
112
+ rng = as_rng(seed)
113
+ if designs is None:
114
+ designs = {
115
+ "srs": srs,
116
+ "systematic-square": lambda d, k, seed: systematic(d, k, rotation="random", seed=seed),
117
+ "systematic-hex": lambda d, k, seed: systematic(
118
+ d, k, shape="hex", rotation="random", seed=seed
119
+ ),
120
+ "poisson-disk": poisson_disk,
121
+ "bas": bas,
122
+ "grts": grts,
123
+ "lpm": lpm,
124
+ }
125
+ frame, _ = grid_frame(dom, frame_size, rng)
126
+ out: Dict[str, Dict[str, float]] = {}
127
+ for name, fn in designs.items():
128
+ vals = []
129
+ sizes = []
130
+ for _ in range(reps):
131
+ smp = fn(dom, n, seed=rng)
132
+ if isinstance(smp, Sample):
133
+ smp = smp.base()
134
+ xy = smp.xy
135
+ else: # pragma: no cover - custom callables
136
+ xy = np.asarray(smp)
137
+ sizes.append(len(xy))
138
+ vals.append(voronoi_balance(xy, frame).value)
139
+ out[name] = {
140
+ "mean": float(np.mean(vals)),
141
+ "sd": float(np.std(vals, ddof=1)) if reps > 1 else 0.0,
142
+ "mean_n": float(np.mean(sizes)),
143
+ }
144
+ return out
spatialsample/bas.py ADDED
@@ -0,0 +1,96 @@
1
+ """Balanced Acceptance Sampling (BAS), Robertson et al. (2013).
2
+
3
+ BAS draws points from a random-start Halton sequence (bases 2 and 3) over the
4
+ bounding box of the domain and keeps those that fall inside it. Because the
5
+ Halton sequence is a low-discrepancy sequence, consecutive accepted points are
6
+ spread evenly, and the design is an equal-probability design in which each
7
+ location has the same inclusion density ``n / area``. The points after the
8
+ first ``n`` form a natural, still well spread, oversample.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, List
14
+
15
+ import numpy as np
16
+
17
+ from ._frame import as_domain
18
+ from ._types import FloatArray, SeedLike, as_rng
19
+ from .sample import Sample
20
+
21
+ __all__ = ["bas", "halton"]
22
+
23
+ _MAX_START = 10_000_000
24
+
25
+
26
+ def halton(index: Any, base: int) -> FloatArray:
27
+ """Radical-inverse (van der Corput) values of non-negative integers in ``base``."""
28
+ i = np.asarray(index, dtype=np.int64).copy()
29
+ if np.any(i < 0):
30
+ raise ValueError("Halton indices must be non-negative")
31
+ out = np.zeros(i.shape, dtype=float)
32
+ f = 1.0
33
+ while np.any(i > 0):
34
+ f /= base
35
+ out += f * (i % base)
36
+ i //= base
37
+ return out
38
+
39
+
40
+ def bas(
41
+ domain: Any,
42
+ n: int,
43
+ n_over: int = 0,
44
+ seed: SeedLike = None,
45
+ ) -> Sample:
46
+ """Draw a Balanced Acceptance Sample of ``n`` sites (plus ``n_over`` oversample sites).
47
+
48
+ Parameters
49
+ ----------
50
+ domain:
51
+ Polygon :class:`~spatialsample.Region`, :class:`~spatialsample.RasterMask`
52
+ or GeoJSON mapping.
53
+ n, n_over:
54
+ Base and oversample sizes; oversample sites continue the Halton sequence.
55
+ seed:
56
+ Seed for the random start ``(k1, k2)`` of the Halton sequence.
57
+ """
58
+ rng = as_rng(seed)
59
+ dom = as_domain(domain)
60
+ if n < 1 or n_over < 0:
61
+ raise ValueError("n must be >= 1 and n_over >= 0")
62
+ m = n + n_over
63
+ x0, y0, x1, y1 = dom.bounds
64
+ k1, k2 = (int(v) for v in rng.integers(0, _MAX_START, 2))
65
+ frac = max(dom.area / ((x1 - x0) * (y1 - y0)), 1e-6)
66
+ got: List[FloatArray] = []
67
+ seq: List[np.ndarray] = []
68
+ have = 0
69
+ start = 0
70
+ for _ in range(100000):
71
+ batch = int(max(64, 1.3 * (m - have) / frac))
72
+ j = np.arange(start, start + batch, dtype=np.int64)
73
+ start += batch
74
+ pts = np.column_stack(
75
+ [x0 + (x1 - x0) * halton(k1 + j, 2), y0 + (y1 - y0) * halton(k2 + j, 3)]
76
+ )
77
+ ok = dom.contains(pts)
78
+ got.append(pts[ok])
79
+ seq.append(j[ok])
80
+ have += int(ok.sum())
81
+ if have >= m:
82
+ break
83
+ xy = np.vstack(got)[:m]
84
+ index = np.concatenate(seq)[:m]
85
+ density = n / dom.area
86
+ return Sample(
87
+ xy=xy,
88
+ pi=np.full(m, density),
89
+ weight=np.full(m, 1.0 / density),
90
+ design="bas",
91
+ kind="continuous",
92
+ site_type=np.array(["base"] * n + ["over"] * n_over, dtype=object),
93
+ order=np.arange(m),
94
+ attrs={"halton_index": index},
95
+ meta={"n": n, "n_over": n_over, "area": dom.area, "halton_start": (k1, k2)},
96
+ )