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/io.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Reading sampling frames and sites from GeoJSON, CSV or (optionally) GeoPandas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Dict, Hashable, List, Optional, Tuple, Union
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .geometry import Region, _iter_polygons
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"read_geojson",
|
|
16
|
+
"read_region",
|
|
17
|
+
"read_strata",
|
|
18
|
+
"read_points",
|
|
19
|
+
"read_lines",
|
|
20
|
+
"read_matrix_csv",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_geojson(path: PathLike) -> Dict[str, Any]:
|
|
27
|
+
"""Load a GeoJSON file as a Python mapping."""
|
|
28
|
+
with open(path, encoding="utf-8") as fh:
|
|
29
|
+
data = json.load(fh)
|
|
30
|
+
if not isinstance(data, dict) or "type" not in data:
|
|
31
|
+
raise ValueError(f"{path} is not a GeoJSON object")
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(src: Any) -> Dict[str, Any]:
|
|
36
|
+
if isinstance(src, dict):
|
|
37
|
+
return src
|
|
38
|
+
if hasattr(src, "__geo_interface__"):
|
|
39
|
+
return dict(src.__geo_interface__)
|
|
40
|
+
path = os.fspath(src)
|
|
41
|
+
if path.lower().endswith((".geojson", ".json")):
|
|
42
|
+
return read_geojson(path)
|
|
43
|
+
if not os.path.exists(path):
|
|
44
|
+
raise FileNotFoundError(path)
|
|
45
|
+
try: # other vector formats through GeoPandas, when installed
|
|
46
|
+
import geopandas as gpd
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"cannot read {path!r}: only GeoJSON is supported without geopandas "
|
|
50
|
+
"(pip install 'spatialsample[geo]')"
|
|
51
|
+
) from exc
|
|
52
|
+
return dict(gpd.read_file(path).__geo_interface__) # pragma: no cover
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_region(src: Any) -> Region:
|
|
56
|
+
"""All polygons of a GeoJSON file/mapping (or GeoPandas object) as one Region."""
|
|
57
|
+
return Region.from_geojson(_load(src))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_strata(src: Any, field: str) -> Dict[Hashable, Region]:
|
|
61
|
+
"""Polygon features grouped by the value of property ``field``."""
|
|
62
|
+
data = _load(src)
|
|
63
|
+
feats = data.get("features") if data.get("type") == "FeatureCollection" else [data]
|
|
64
|
+
groups: Dict[Hashable, List[Any]] = {}
|
|
65
|
+
for f in feats or []:
|
|
66
|
+
props = f.get("properties") or {}
|
|
67
|
+
if field not in props:
|
|
68
|
+
raise KeyError(f"feature without property {field!r}")
|
|
69
|
+
polys = list(_iter_polygons(f))
|
|
70
|
+
if polys:
|
|
71
|
+
groups.setdefault(props[field], []).extend(polys)
|
|
72
|
+
if not groups:
|
|
73
|
+
raise ValueError("no polygon features found")
|
|
74
|
+
return {k: Region(v) for k, v in groups.items()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_points(src: Any) -> Tuple[np.ndarray, List[Dict[str, Any]]]:
|
|
78
|
+
"""Point coordinates and properties from GeoJSON (Point/MultiPoint) or CSV.
|
|
79
|
+
|
|
80
|
+
CSV files need ``x`` and ``y`` columns (``lon``/``lat`` also accepted).
|
|
81
|
+
"""
|
|
82
|
+
if not isinstance(src, dict) and os.fspath(src).lower().endswith(".csv"):
|
|
83
|
+
return _read_points_csv(os.fspath(src))
|
|
84
|
+
data = _load(src)
|
|
85
|
+
xy: List[List[float]] = []
|
|
86
|
+
props: List[Dict[str, Any]] = []
|
|
87
|
+
|
|
88
|
+
def visit(obj: Dict[str, Any], p: Dict[str, Any]) -> None:
|
|
89
|
+
t = obj.get("type")
|
|
90
|
+
if t == "FeatureCollection":
|
|
91
|
+
for f in obj.get("features", []):
|
|
92
|
+
visit(f, {})
|
|
93
|
+
elif t == "Feature" and obj.get("geometry"):
|
|
94
|
+
visit(obj["geometry"], obj.get("properties") or {})
|
|
95
|
+
elif t == "Point":
|
|
96
|
+
xy.append(list(obj["coordinates"][:2]))
|
|
97
|
+
props.append(p)
|
|
98
|
+
elif t == "MultiPoint":
|
|
99
|
+
for c in obj["coordinates"]:
|
|
100
|
+
xy.append(list(c[:2]))
|
|
101
|
+
props.append(p)
|
|
102
|
+
|
|
103
|
+
visit(data, {})
|
|
104
|
+
if not xy:
|
|
105
|
+
raise ValueError("no point geometries found")
|
|
106
|
+
return np.asarray(xy, dtype=float), props
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_points_csv(path: str) -> Tuple[np.ndarray, List[Dict[str, Any]]]:
|
|
110
|
+
with open(path, newline="", encoding="utf-8") as fh:
|
|
111
|
+
rows = list(csv.DictReader(fh))
|
|
112
|
+
if not rows:
|
|
113
|
+
raise ValueError("empty CSV")
|
|
114
|
+
keys = {k.lower(): k for k in rows[0]}
|
|
115
|
+
xk = keys.get("x") or keys.get("lon") or keys.get("longitude")
|
|
116
|
+
yk = keys.get("y") or keys.get("lat") or keys.get("latitude")
|
|
117
|
+
if xk is None or yk is None:
|
|
118
|
+
raise ValueError("CSV needs x/y (or lon/lat) columns")
|
|
119
|
+
xy = np.array([[float(r[xk]), float(r[yk])] for r in rows])
|
|
120
|
+
return xy, [dict(r) for r in rows]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_lines(src: Any) -> List[np.ndarray]:
|
|
124
|
+
"""LineString / MultiLineString coordinates from GeoJSON."""
|
|
125
|
+
from .designs import _as_lines
|
|
126
|
+
|
|
127
|
+
return _as_lines(_load(src))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_matrix_csv(path: PathLike) -> Tuple[np.ndarray, Optional[List[str]]]:
|
|
131
|
+
"""Read a square error matrix from CSV.
|
|
132
|
+
|
|
133
|
+
A header row and/or a first label column are detected automatically
|
|
134
|
+
(rows = map classes, columns = reference classes).
|
|
135
|
+
"""
|
|
136
|
+
with open(path, newline="", encoding="utf-8") as fh:
|
|
137
|
+
rows = [r for r in csv.reader(fh) if any(c.strip() for c in r)]
|
|
138
|
+
if not rows:
|
|
139
|
+
raise ValueError("empty matrix file")
|
|
140
|
+
|
|
141
|
+
def numeric(s: str) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
float(s)
|
|
144
|
+
return True
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
labels: Optional[List[str]] = None
|
|
149
|
+
if not all(numeric(c) for c in rows[0] if c.strip()):
|
|
150
|
+
header = rows[0]
|
|
151
|
+
rows = rows[1:]
|
|
152
|
+
labels = [c.strip() for c in header]
|
|
153
|
+
first_col_labels = any(not numeric(r[0]) for r in rows)
|
|
154
|
+
if first_col_labels:
|
|
155
|
+
row_labels = [r[0].strip() for r in rows]
|
|
156
|
+
rows = [r[1:] for r in rows]
|
|
157
|
+
labels = row_labels
|
|
158
|
+
mat = np.array([[float(c) for c in r if c.strip() != ""] for r in rows])
|
|
159
|
+
if labels is not None and len(labels) != mat.shape[0]:
|
|
160
|
+
labels = labels[-mat.shape[0] :] if len(labels) > mat.shape[0] else None
|
|
161
|
+
return mat, labels
|
spatialsample/lpm.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Local Pivotal Method (LPM2), Grafström, Lundström & Schelin (2012).
|
|
2
|
+
|
|
3
|
+
The pivotal method repeatedly picks two undecided units and moves inclusion
|
|
4
|
+
probability between them so that at least one of the two is decided (its
|
|
5
|
+
probability becomes 0 or 1), while the expected value of each unit's
|
|
6
|
+
probability stays the same. Choosing the pair as a randomly drawn unit and its
|
|
7
|
+
*nearest* undecided neighbour (LPM2) makes nearby units unlikely to be
|
|
8
|
+
selected together, which spreads the sample in space. Each unit is included
|
|
9
|
+
with exactly its prescribed first-order inclusion probability.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ._frame import ProbLike, frame_sizes, grid_frame, jitter_in_cells, resolve
|
|
19
|
+
from ._types import FloatArray, SeedLike, as_rng
|
|
20
|
+
from .probabilities import inclusion_probabilities
|
|
21
|
+
from .sample import Sample
|
|
22
|
+
|
|
23
|
+
__all__ = ["lpm", "lpm2_select"]
|
|
24
|
+
|
|
25
|
+
_EPS = 1e-10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lpm2_select(xy: FloatArray, pi: FloatArray, seed: SeedLike = None) -> np.ndarray:
|
|
29
|
+
"""Run LPM2 and return the indices of the selected units.
|
|
30
|
+
|
|
31
|
+
``pi`` are the target inclusion probabilities (they should sum to an
|
|
32
|
+
integer). Runs in ``O(N^2)`` time; frames up to a few tens of thousands of
|
|
33
|
+
units are practical.
|
|
34
|
+
"""
|
|
35
|
+
rng = as_rng(seed)
|
|
36
|
+
p = np.asarray(pi, dtype=float).copy()
|
|
37
|
+
pts = np.asarray(xy, dtype=float)
|
|
38
|
+
if len(p) != len(pts):
|
|
39
|
+
raise ValueError("xy and pi must have the same length")
|
|
40
|
+
if np.any(p < -_EPS) or np.any(p > 1 + _EPS):
|
|
41
|
+
raise ValueError("inclusion probabilities must lie in [0, 1]")
|
|
42
|
+
active = np.flatnonzero((p > _EPS) & (p < 1 - _EPS))
|
|
43
|
+
ax = pts[active].copy()
|
|
44
|
+
na = len(active)
|
|
45
|
+
while na > 1:
|
|
46
|
+
a = int(rng.integers(na))
|
|
47
|
+
d = (ax[:na, 0] - ax[a, 0]) ** 2 + (ax[:na, 1] - ax[a, 1]) ** 2
|
|
48
|
+
d[a] = np.inf
|
|
49
|
+
dmin = d.min()
|
|
50
|
+
ties = np.flatnonzero(d == dmin)
|
|
51
|
+
b = int(ties[rng.integers(len(ties))]) if len(ties) > 1 else int(ties[0])
|
|
52
|
+
i, j = active[a], active[b]
|
|
53
|
+
pi_i, pi_j = p[i], p[j]
|
|
54
|
+
s = pi_i + pi_j
|
|
55
|
+
u = rng.random()
|
|
56
|
+
if s < 1.0:
|
|
57
|
+
if u * s < pi_j:
|
|
58
|
+
p[i], p[j] = 0.0, s
|
|
59
|
+
else:
|
|
60
|
+
p[i], p[j] = s, 0.0
|
|
61
|
+
else:
|
|
62
|
+
if u * (2.0 - s) < 1.0 - pi_j:
|
|
63
|
+
p[i], p[j] = 1.0, s - 1.0
|
|
64
|
+
else:
|
|
65
|
+
p[i], p[j] = s - 1.0, 1.0
|
|
66
|
+
# drop decided units from the active set (swap with the last active one)
|
|
67
|
+
for pos in sorted((a, b), reverse=True):
|
|
68
|
+
q = p[active[pos]]
|
|
69
|
+
if q <= _EPS or q >= 1 - _EPS:
|
|
70
|
+
na -= 1
|
|
71
|
+
active[pos], active[na] = active[na], active[pos]
|
|
72
|
+
ax[[pos, na]] = ax[[na, pos]]
|
|
73
|
+
if na == 1: # numerical leftover
|
|
74
|
+
k = active[0]
|
|
75
|
+
p[k] = 1.0 if rng.random() < p[k] else 0.0
|
|
76
|
+
return np.flatnonzero(p >= 0.5)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def lpm(
|
|
80
|
+
frame: Any,
|
|
81
|
+
n: int,
|
|
82
|
+
prob: ProbLike = None,
|
|
83
|
+
frame_size: Optional[int] = None,
|
|
84
|
+
seed: SeedLike = None,
|
|
85
|
+
) -> Sample:
|
|
86
|
+
"""Draw a spatially balanced sample with the local pivotal method (LPM2).
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
frame:
|
|
91
|
+
Polygon/raster domain (continuous design over a dense grid frame) or an
|
|
92
|
+
``(N, 2)`` array of candidate sites (finite design).
|
|
93
|
+
n:
|
|
94
|
+
Sample size.
|
|
95
|
+
prob:
|
|
96
|
+
Optional size measure; inclusion probabilities are proportional to it
|
|
97
|
+
(units that would exceed 1 are taken with certainty).
|
|
98
|
+
frame_size:
|
|
99
|
+
Grid cells used for a continuous domain (default ``max(2000, 40 * n)``).
|
|
100
|
+
seed:
|
|
101
|
+
Seed or generator.
|
|
102
|
+
"""
|
|
103
|
+
rng = as_rng(seed)
|
|
104
|
+
if n < 1:
|
|
105
|
+
raise ValueError("n must be >= 1")
|
|
106
|
+
domain, pts = resolve(frame)
|
|
107
|
+
if pts is not None:
|
|
108
|
+
pi = inclusion_probabilities(frame_sizes(prob, pts), n)
|
|
109
|
+
sel = lpm2_select(pts, pi, rng)
|
|
110
|
+
return Sample(
|
|
111
|
+
xy=pts[sel],
|
|
112
|
+
pi=pi[sel],
|
|
113
|
+
weight=1.0 / pi[sel],
|
|
114
|
+
design="lpm",
|
|
115
|
+
kind="finite",
|
|
116
|
+
index=sel,
|
|
117
|
+
meta={"n": n, "population_size": len(pts)},
|
|
118
|
+
)
|
|
119
|
+
assert domain is not None
|
|
120
|
+
size = frame_size or max(2000, 40 * n)
|
|
121
|
+
cells, h = grid_frame(domain, size, rng, min_points=2 * n)
|
|
122
|
+
pi = inclusion_probabilities(frame_sizes(prob, cells), n)
|
|
123
|
+
sel = lpm2_select(cells, pi, rng)
|
|
124
|
+
xy = jitter_in_cells(cells[sel], h, domain, rng)
|
|
125
|
+
density = np.full(len(sel), n / domain.area) if prob is None else pi[sel] / (h * h)
|
|
126
|
+
return Sample(
|
|
127
|
+
xy=xy,
|
|
128
|
+
pi=density,
|
|
129
|
+
weight=1.0 / density,
|
|
130
|
+
design="lpm",
|
|
131
|
+
kind="continuous",
|
|
132
|
+
meta={"n": n, "area": domain.area, "frame_size": len(cells)},
|
|
133
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Inclusion probabilities, sample allocation and nearest-neighbour helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ._types import FloatArray
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"inclusion_probabilities",
|
|
13
|
+
"allocate",
|
|
14
|
+
"systematic_pps",
|
|
15
|
+
"nearest_index",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def inclusion_probabilities(size: Any, n: float) -> FloatArray:
|
|
20
|
+
"""Inclusion probabilities proportional to ``size`` that sum to ``n``.
|
|
21
|
+
|
|
22
|
+
Units whose scaled value would exceed one are taken with certainty
|
|
23
|
+
(probability 1) and the remainder is rescaled, iteratively, exactly as in
|
|
24
|
+
the classic ``inclusionprobabilities`` routine of Tillé.
|
|
25
|
+
"""
|
|
26
|
+
a = np.asarray(size, dtype=float).ravel()
|
|
27
|
+
if np.any(a < 0) or not np.all(np.isfinite(a)):
|
|
28
|
+
raise ValueError("size measures must be finite and non-negative")
|
|
29
|
+
if n < 0 or n > np.count_nonzero(a):
|
|
30
|
+
raise ValueError(f"n={n} must be between 0 and the number of positive sizes")
|
|
31
|
+
pi = np.zeros_like(a)
|
|
32
|
+
if n == 0:
|
|
33
|
+
return pi
|
|
34
|
+
cert = np.zeros(len(a), dtype=bool)
|
|
35
|
+
for _ in range(len(a) + 1):
|
|
36
|
+
rest = ~cert & (a > 0)
|
|
37
|
+
left = n - cert.sum()
|
|
38
|
+
pi[cert] = 1.0
|
|
39
|
+
tot = a[rest].sum()
|
|
40
|
+
pi[rest] = left * a[rest] / tot if tot > 0 else 0.0
|
|
41
|
+
over = rest & (pi >= 1.0)
|
|
42
|
+
if not over.any():
|
|
43
|
+
break
|
|
44
|
+
cert |= over
|
|
45
|
+
return np.minimum(pi, 1.0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def systematic_pps(pi: FloatArray, rng: np.random.Generator) -> np.ndarray:
|
|
49
|
+
"""Systematic selection along the given order with probabilities ``pi``.
|
|
50
|
+
|
|
51
|
+
``pi`` must sum to an integer ``n``; returns the selected positions
|
|
52
|
+
(exactly ``n``), each unit chosen with probability ``pi_i``.
|
|
53
|
+
"""
|
|
54
|
+
cum = np.cumsum(pi)
|
|
55
|
+
n = int(round(float(cum[-1]))) if len(cum) else 0
|
|
56
|
+
if n == 0:
|
|
57
|
+
return np.empty(0, dtype=np.int64)
|
|
58
|
+
cum = cum * (n / cum[-1])
|
|
59
|
+
targets = rng.uniform() + np.arange(n)
|
|
60
|
+
pos = np.searchsorted(cum, targets, side="right")
|
|
61
|
+
return np.minimum(pos, len(pi) - 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _largest_remainder(
|
|
65
|
+
n: int, share: FloatArray, minimum: FloatArray, cap: FloatArray
|
|
66
|
+
) -> np.ndarray:
|
|
67
|
+
k = len(share)
|
|
68
|
+
alloc = np.minimum(minimum.astype(np.int64), cap.astype(np.int64))
|
|
69
|
+
for _ in range(4 * k + 10):
|
|
70
|
+
left = n - alloc.sum()
|
|
71
|
+
if left <= 0:
|
|
72
|
+
break
|
|
73
|
+
open_ = alloc < cap
|
|
74
|
+
if not open_.any():
|
|
75
|
+
break
|
|
76
|
+
s = np.where(open_, share, 0.0)
|
|
77
|
+
if s.sum() <= 0:
|
|
78
|
+
s = open_.astype(float)
|
|
79
|
+
ideal = left * s / s.sum()
|
|
80
|
+
add = np.minimum(np.floor(ideal).astype(np.int64), (cap - alloc).astype(np.int64))
|
|
81
|
+
if add.sum() == 0:
|
|
82
|
+
rem = np.where(open_, ideal - np.floor(ideal) + 1e-12 * s, -np.inf)
|
|
83
|
+
j = int(np.argmax(rem))
|
|
84
|
+
add = np.zeros(k, dtype=np.int64)
|
|
85
|
+
add[j] = 1
|
|
86
|
+
alloc = alloc + add
|
|
87
|
+
return alloc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def allocate(
|
|
91
|
+
n: int,
|
|
92
|
+
sizes: Union[Mapping[Hashable, float], Sequence[float]],
|
|
93
|
+
method: str = "proportional",
|
|
94
|
+
sd: Optional[Union[Mapping[Hashable, float], Sequence[float]]] = None,
|
|
95
|
+
min_per_stratum: int = 0,
|
|
96
|
+
caps: Optional[Union[Mapping[Hashable, float], Sequence[float]]] = None,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""Split a total sample size ``n`` among strata.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
n:
|
|
103
|
+
Total sample size.
|
|
104
|
+
sizes:
|
|
105
|
+
Stratum sizes (areas or unit counts), as a mapping or a sequence.
|
|
106
|
+
method:
|
|
107
|
+
``"proportional"`` (n_h proportional to size), ``"equal"`` or
|
|
108
|
+
``"neyman"`` (n_h proportional to size times standard deviation).
|
|
109
|
+
sd:
|
|
110
|
+
Stratum standard deviations, required for Neyman allocation.
|
|
111
|
+
min_per_stratum:
|
|
112
|
+
Lower bound for every stratum (useful for rare map classes).
|
|
113
|
+
caps:
|
|
114
|
+
Optional upper bounds (for finite strata: the number of units).
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
A mapping when ``sizes`` is a mapping, otherwise a list of ints. Rounding
|
|
119
|
+
uses the largest-remainder method so the result sums to ``n``.
|
|
120
|
+
"""
|
|
121
|
+
keys: List[Hashable]
|
|
122
|
+
if isinstance(sizes, Mapping):
|
|
123
|
+
keys = list(sizes)
|
|
124
|
+
sz = np.array([float(sizes[k]) for k in keys])
|
|
125
|
+
else:
|
|
126
|
+
keys = list(range(len(sizes)))
|
|
127
|
+
sz = np.asarray(sizes, dtype=float)
|
|
128
|
+
|
|
129
|
+
def _vec(v: Any) -> FloatArray:
|
|
130
|
+
if isinstance(v, Mapping):
|
|
131
|
+
return np.array([float(v[k]) for k in keys])
|
|
132
|
+
return np.asarray(v, dtype=float)
|
|
133
|
+
|
|
134
|
+
if np.any(sz < 0):
|
|
135
|
+
raise ValueError("stratum sizes must be non-negative")
|
|
136
|
+
method = method.lower()
|
|
137
|
+
if method == "proportional":
|
|
138
|
+
share = sz
|
|
139
|
+
elif method == "equal":
|
|
140
|
+
share = (sz > 0).astype(float)
|
|
141
|
+
elif method == "neyman":
|
|
142
|
+
if sd is None:
|
|
143
|
+
raise ValueError("Neyman allocation needs per-stratum standard deviations (sd=)")
|
|
144
|
+
share = sz * _vec(sd)
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError("method must be 'proportional', 'equal' or 'neyman'")
|
|
147
|
+
cap = _vec(caps) if caps is not None else np.full(len(sz), float(n))
|
|
148
|
+
cap = np.floor(cap)
|
|
149
|
+
if n > cap.sum():
|
|
150
|
+
raise ValueError("n exceeds the total capacity of the strata")
|
|
151
|
+
minimum = np.full(len(sz), float(min_per_stratum))
|
|
152
|
+
if np.minimum(minimum, cap).sum() > n:
|
|
153
|
+
raise ValueError("min_per_stratum * number of strata exceeds n")
|
|
154
|
+
alloc = _largest_remainder(int(n), share.astype(float), minimum, cap)
|
|
155
|
+
if isinstance(sizes, Mapping):
|
|
156
|
+
return {k: int(a) for k, a in zip(keys, alloc)}
|
|
157
|
+
return [int(a) for a in alloc]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def nearest_index(query: FloatArray, ref: FloatArray) -> np.ndarray:
|
|
161
|
+
"""Index of the nearest ``ref`` point for each ``query`` point.
|
|
162
|
+
|
|
163
|
+
Uses ``scipy.spatial.cKDTree`` when SciPy is installed, otherwise a chunked
|
|
164
|
+
NumPy brute-force search.
|
|
165
|
+
"""
|
|
166
|
+
q = np.asarray(query, dtype=float).reshape(-1, 2)
|
|
167
|
+
r = np.asarray(ref, dtype=float).reshape(-1, 2)
|
|
168
|
+
if len(r) == 0:
|
|
169
|
+
raise ValueError("reference set is empty")
|
|
170
|
+
try:
|
|
171
|
+
from scipy.spatial import cKDTree
|
|
172
|
+
except ImportError: # pragma: no cover - depends on environment
|
|
173
|
+
cKDTree = None
|
|
174
|
+
if cKDTree is not None:
|
|
175
|
+
_, idx = cKDTree(r).query(q)
|
|
176
|
+
return np.asarray(idx, dtype=np.int64)
|
|
177
|
+
return _brute_nearest(q, r)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _brute_nearest(q: FloatArray, r: FloatArray) -> np.ndarray:
|
|
181
|
+
out = np.empty(len(q), dtype=np.int64)
|
|
182
|
+
step = max(1, int(4_000_000 // max(len(r), 1)))
|
|
183
|
+
for s in range(0, len(q), step):
|
|
184
|
+
blk = q[s : s + step]
|
|
185
|
+
d = (blk[:, None, 0] - r[None, :, 0]) ** 2 + (blk[:, None, 1] - r[None, :, 1]) ** 2
|
|
186
|
+
out[s : s + step] = np.argmin(d, axis=1)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def knn_indices(xy: FloatArray, k: int) -> np.ndarray:
|
|
191
|
+
"""``(n, k)`` indices of each point's ``k`` nearest points, itself first."""
|
|
192
|
+
p = np.asarray(xy, dtype=float).reshape(-1, 2)
|
|
193
|
+
k = min(k, len(p))
|
|
194
|
+
d = ((p[:, None, :] - p[None, :, :]) ** 2).sum(-1)
|
|
195
|
+
np.fill_diagonal(d, -1.0)
|
|
196
|
+
return np.argsort(d, axis=1, kind="stable")[:, :k]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def as_label_dict(values: Any, keys: Sequence[Hashable]) -> Dict[Hashable, float]:
|
|
200
|
+
if isinstance(values, Mapping):
|
|
201
|
+
return {k: float(values[k]) for k in keys}
|
|
202
|
+
return {k: float(v) for k, v in zip(keys, values)}
|
spatialsample/py.typed
ADDED
|
File without changes
|
spatialsample/raster.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Raster masks and classified rasters as sampling domains.
|
|
2
|
+
|
|
3
|
+
A :class:`RasterMask` wraps a 2D array (for example a land-cover map) together
|
|
4
|
+
with a north-up geotransform. It behaves like a polygon :class:`~spatialsample.Region`
|
|
5
|
+
(``area``, ``bounds``, ``contains``, ``random_points``) so every continuous design
|
|
6
|
+
works on it, and it can also expose its cells as a finite population of pixels
|
|
7
|
+
for pixel-based designs such as :func:`spatialsample.stratified_pixels`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Iterable, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from ._types import FloatArray, SeedLike, as_rng
|
|
17
|
+
|
|
18
|
+
__all__ = ["RasterMask"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RasterMask:
|
|
22
|
+
"""A north-up raster whose valid cells form the sampling domain.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
data:
|
|
27
|
+
2D array of cell values (class codes or a 0/1 mask).
|
|
28
|
+
origin:
|
|
29
|
+
``(x_min, y_max)`` of the upper-left corner of the raster.
|
|
30
|
+
cell_size:
|
|
31
|
+
Cell width, or ``(width, height)``; both positive.
|
|
32
|
+
nodata:
|
|
33
|
+
Value marking cells outside the domain. NaN cells are always excluded.
|
|
34
|
+
classes:
|
|
35
|
+
Optional subset of values that count as part of the domain. When the
|
|
36
|
+
array is boolean and ``classes`` is ``None``, only ``True`` cells count.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
data: Any,
|
|
42
|
+
origin: Tuple[float, float] = (0.0, 0.0),
|
|
43
|
+
cell_size: Union[float, Tuple[float, float]] = 1.0,
|
|
44
|
+
nodata: Optional[float] = None,
|
|
45
|
+
classes: Optional[Iterable[Any]] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
arr = np.asarray(data)
|
|
48
|
+
if arr.ndim != 2:
|
|
49
|
+
raise ValueError("raster data must be 2D")
|
|
50
|
+
cs = np.atleast_1d(np.asarray(cell_size, dtype=float))
|
|
51
|
+
cw = float(cs[0])
|
|
52
|
+
ch = float(cs[-1])
|
|
53
|
+
if cw <= 0 or ch <= 0:
|
|
54
|
+
raise ValueError("cell_size must be positive")
|
|
55
|
+
self.data = arr
|
|
56
|
+
self.origin = (float(origin[0]), float(origin[1]))
|
|
57
|
+
self.cell_size = (cw, ch)
|
|
58
|
+
self.nodata = nodata
|
|
59
|
+
valid = np.ones(arr.shape, dtype=bool)
|
|
60
|
+
if arr.dtype == bool and classes is None:
|
|
61
|
+
valid &= arr
|
|
62
|
+
if np.issubdtype(arr.dtype, np.floating):
|
|
63
|
+
valid &= np.isfinite(arr)
|
|
64
|
+
if nodata is not None:
|
|
65
|
+
valid &= arr != nodata
|
|
66
|
+
self.classes: Optional[Tuple[Any, ...]] = None
|
|
67
|
+
if classes is not None:
|
|
68
|
+
self.classes = tuple(classes)
|
|
69
|
+
valid &= np.isin(arr, np.asarray(self.classes))
|
|
70
|
+
self.valid = valid
|
|
71
|
+
self._flat = np.flatnonzero(valid.ravel())
|
|
72
|
+
if len(self._flat) == 0:
|
|
73
|
+
raise ValueError("raster mask has no valid cells")
|
|
74
|
+
rows, cols = np.divmod(self._flat, arr.shape[1])
|
|
75
|
+
x0 = self.origin[0] + cols.min() * cw
|
|
76
|
+
x1 = self.origin[0] + (cols.max() + 1) * cw
|
|
77
|
+
y1 = self.origin[1] - rows.min() * ch
|
|
78
|
+
y0 = self.origin[1] - (rows.max() + 1) * ch
|
|
79
|
+
self.bounds: Tuple[float, float, float, float] = (
|
|
80
|
+
float(x0),
|
|
81
|
+
float(y0),
|
|
82
|
+
float(x1),
|
|
83
|
+
float(y1),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_affine(cls, data: Any, transform: Sequence[float], **kwargs: Any) -> RasterMask:
|
|
88
|
+
"""Build from a rasterio/GDAL-style affine ``(a, b, c, d, e, f)``.
|
|
89
|
+
|
|
90
|
+
Only north-up transforms (``b == d == 0``, ``e < 0``) are supported.
|
|
91
|
+
"""
|
|
92
|
+
a, b, c, d, e, f = (float(v) for v in list(transform)[:6])
|
|
93
|
+
if b != 0 or d != 0 or e >= 0:
|
|
94
|
+
raise ValueError("only north-up rasters (no rotation, negative e) are supported")
|
|
95
|
+
return cls(data, origin=(c, f), cell_size=(a, -e), **kwargs)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ properties
|
|
98
|
+
@property
|
|
99
|
+
def cell_area(self) -> float:
|
|
100
|
+
return self.cell_size[0] * self.cell_size[1]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def n_cells(self) -> int:
|
|
104
|
+
return int(len(self._flat))
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def area(self) -> float:
|
|
108
|
+
return self.n_cells * self.cell_area
|
|
109
|
+
|
|
110
|
+
def select(self, classes: Iterable[Any]) -> RasterMask:
|
|
111
|
+
"""A new mask restricted to the given class values."""
|
|
112
|
+
return RasterMask(
|
|
113
|
+
self.data, self.origin, self.cell_size, nodata=self.nodata, classes=classes
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def class_areas(self) -> Dict[Any, float]:
|
|
117
|
+
"""Area of every distinct value among the valid cells."""
|
|
118
|
+
vals, counts = np.unique(self.data.ravel()[self._flat], return_counts=True)
|
|
119
|
+
return {_py(v): float(c) * self.cell_area for v, c in zip(vals, counts)}
|
|
120
|
+
|
|
121
|
+
def class_counts(self) -> Dict[Any, int]:
|
|
122
|
+
vals, counts = np.unique(self.data.ravel()[self._flat], return_counts=True)
|
|
123
|
+
return {_py(v): int(c) for v, c in zip(vals, counts)}
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ geometry
|
|
126
|
+
def rowcol(self, xy: Any) -> Tuple[np.ndarray, np.ndarray]:
|
|
127
|
+
pts = np.atleast_2d(np.asarray(xy, dtype=float))
|
|
128
|
+
col = np.floor((pts[:, 0] - self.origin[0]) / self.cell_size[0]).astype(np.int64)
|
|
129
|
+
row = np.floor((self.origin[1] - pts[:, 1]) / self.cell_size[1]).astype(np.int64)
|
|
130
|
+
return row, col
|
|
131
|
+
|
|
132
|
+
def contains(self, xy: Any) -> np.ndarray:
|
|
133
|
+
row, col = self.rowcol(xy)
|
|
134
|
+
h, w = self.data.shape
|
|
135
|
+
ok = (row >= 0) & (row < h) & (col >= 0) & (col < w)
|
|
136
|
+
out = np.zeros(len(row), dtype=bool)
|
|
137
|
+
out[ok] = self.valid[row[ok], col[ok]]
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
def values_at(self, xy: Any) -> np.ndarray:
|
|
141
|
+
"""Cell values at points (points must fall inside the raster extent)."""
|
|
142
|
+
row, col = self.rowcol(xy)
|
|
143
|
+
return self.data[row, col]
|
|
144
|
+
|
|
145
|
+
def cell_centers(self) -> Tuple[FloatArray, np.ndarray, np.ndarray]:
|
|
146
|
+
"""``(xy, row, col)`` of every valid cell centre."""
|
|
147
|
+
h, w = self.data.shape
|
|
148
|
+
row, col = np.divmod(self._flat, w)
|
|
149
|
+
x = self.origin[0] + (col + 0.5) * self.cell_size[0]
|
|
150
|
+
y = self.origin[1] - (row + 0.5) * self.cell_size[1]
|
|
151
|
+
return np.column_stack([x, y]), row, col
|
|
152
|
+
|
|
153
|
+
def random_points(self, n: int, seed: SeedLike = None) -> FloatArray:
|
|
154
|
+
"""``n`` independent points uniform over the valid cells."""
|
|
155
|
+
rng = as_rng(seed)
|
|
156
|
+
if n < 0:
|
|
157
|
+
raise ValueError("n must be non-negative")
|
|
158
|
+
pick = self._flat[rng.integers(0, len(self._flat), n)]
|
|
159
|
+
w = self.data.shape[1]
|
|
160
|
+
row, col = np.divmod(pick, w)
|
|
161
|
+
x = self.origin[0] + (col + rng.random(n)) * self.cell_size[0]
|
|
162
|
+
y = self.origin[1] - (row + rng.random(n)) * self.cell_size[1]
|
|
163
|
+
return np.column_stack([x, y])
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
return f"RasterMask(shape={self.data.shape}, cells={self.n_cells}, area={self.area:.6g})"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _py(v: Any) -> Any:
|
|
170
|
+
return v.item() if hasattr(v, "item") else v
|