geomask 0.2.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.
geomask/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """geomask - geographic masking of confidential point data.
2
+
3
+ Masks (random perturbation, Gaussian displacement, donut and
4
+ population-adaptive donut masking, grid / hexagon / Voronoi aggregation with
5
+ small-cell suppression, location swapping, adaptive areal elimination) plus
6
+ privacy metrics (spatial k-anonymity, re-identification risk) and utility
7
+ metrics (Ripley's L, kernel-density correlation, Moran's I) to document the
8
+ trade-off between them.
9
+
10
+ All masks work on projected coordinates in metres; see
11
+ :mod:`geomask.projection` for lon/lat helpers. Masking is not anonymisation
12
+ on its own - see the README's ethics section.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ from ._spatial import get_backend, set_backend
20
+ from .aae import AAEResult, adaptive_areal_elimination
21
+ from .aggregation import (
22
+ AggregationResult,
23
+ aggregate_by_label,
24
+ aggregate_to_grid,
25
+ aggregate_to_voronoi,
26
+ )
27
+ from .evaluate import EvaluationResult, compare, evaluate, report
28
+ from .masking import (
29
+ DonutResult,
30
+ donut_mask,
31
+ gaussian_displacement,
32
+ location_swap,
33
+ random_perturbation,
34
+ snap_to_grid,
35
+ snap_to_hex,
36
+ )
37
+ from .privacy import (
38
+ DisplacementStats,
39
+ displacement,
40
+ displacement_stats,
41
+ expected_reidentification_risk,
42
+ nearest_neighbour_reidentification_rate,
43
+ spatial_k_anonymity,
44
+ )
45
+ from .projection import UTM, LocalEquirectangular, make_projection, utm_zone
46
+ from .synthetic import SyntheticCity, synthetic_city
47
+ from .utility import (
48
+ ClusterPreservation,
49
+ RipleyComparison,
50
+ cluster_preservation,
51
+ kde_correlation,
52
+ kernel_density,
53
+ mean_displacement,
54
+ morans_i,
55
+ ripley_k,
56
+ ripley_l,
57
+ ripley_l_difference,
58
+ )
59
+
60
+ __all__ = [
61
+ "AAEResult",
62
+ "AggregationResult",
63
+ "ClusterPreservation",
64
+ "DisplacementStats",
65
+ "DonutResult",
66
+ "EvaluationResult",
67
+ "LocalEquirectangular",
68
+ "RipleyComparison",
69
+ "SyntheticCity",
70
+ "UTM",
71
+ "__version__",
72
+ "adaptive_areal_elimination",
73
+ "aggregate_by_label",
74
+ "aggregate_to_grid",
75
+ "aggregate_to_voronoi",
76
+ "cluster_preservation",
77
+ "compare",
78
+ "displacement",
79
+ "displacement_stats",
80
+ "donut_mask",
81
+ "evaluate",
82
+ "expected_reidentification_risk",
83
+ "gaussian_displacement",
84
+ "get_backend",
85
+ "kde_correlation",
86
+ "kernel_density",
87
+ "location_swap",
88
+ "make_projection",
89
+ "mean_displacement",
90
+ "morans_i",
91
+ "nearest_neighbour_reidentification_rate",
92
+ "random_perturbation",
93
+ "report",
94
+ "ripley_k",
95
+ "ripley_l",
96
+ "ripley_l_difference",
97
+ "set_backend",
98
+ "snap_to_grid",
99
+ "snap_to_hex",
100
+ "spatial_k_anonymity",
101
+ "synthetic_city",
102
+ "utm_zone",
103
+ ]
geomask/_spatial.py ADDED
@@ -0,0 +1,178 @@
1
+ """Neighbour queries with an optional SciPy KD-tree and a pure-NumPy fallback.
2
+
3
+ Every function here gives identical results with either backend; SciPy only
4
+ makes them faster on large inputs. The backend can be forced with
5
+ :func:`set_backend` (useful for testing) or the ``GEOMASK_NO_SCIPY``
6
+ environment variable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ try: # pragma: no cover - exercised implicitly depending on the environment
17
+ from scipy.spatial import cKDTree as _KDTree
18
+
19
+ _HAVE_SCIPY = True
20
+ except Exception: # pragma: no cover
21
+ _KDTree = None
22
+ _HAVE_SCIPY = False
23
+
24
+ _use_scipy = _HAVE_SCIPY and not os.environ.get("GEOMASK_NO_SCIPY")
25
+
26
+ #: Upper bound on the number of pairwise distances held in memory at once by
27
+ #: the NumPy fallback.
28
+ _CHUNK_ELEMENTS = 2_000_000
29
+
30
+
31
+ def set_backend(name: str) -> str:
32
+ """Select the neighbour-search backend: ``"scipy"``, ``"numpy"`` or ``"auto"``.
33
+
34
+ Returns the backend actually in use. Asking for ``"scipy"`` when SciPy is
35
+ not installed raises :class:`ImportError`.
36
+ """
37
+ global _use_scipy
38
+ if name == "auto":
39
+ _use_scipy = _HAVE_SCIPY
40
+ elif name == "scipy":
41
+ if not _HAVE_SCIPY:
42
+ raise ImportError("SciPy is not installed; pip install 'geomask[fast]'")
43
+ _use_scipy = True
44
+ elif name == "numpy":
45
+ _use_scipy = False
46
+ else:
47
+ raise ValueError(f"unknown backend {name!r}; use 'scipy', 'numpy' or 'auto'")
48
+ return get_backend()
49
+
50
+
51
+ def get_backend() -> str:
52
+ """Return the name of the active backend (``"scipy"`` or ``"numpy"``)."""
53
+ return "scipy" if _use_scipy else "numpy"
54
+
55
+
56
+ def _step(n_ref: int) -> int:
57
+ return max(1, _CHUNK_ELEMENTS // max(1, n_ref))
58
+
59
+
60
+ def _dist_block(query: np.ndarray, ref: np.ndarray) -> np.ndarray:
61
+ diff = query[:, None, :] - ref[None, :, :]
62
+ return np.sqrt(np.einsum("ijk,ijk->ij", diff, diff))
63
+
64
+
65
+ def _tree(ref: np.ndarray) -> Any:
66
+ assert _KDTree is not None
67
+ return _KDTree(ref)
68
+
69
+
70
+ def count_closer(ref: np.ndarray, centers: np.ndarray, radii: np.ndarray) -> np.ndarray:
71
+ """Count reference points *strictly* closer than ``radii[i]`` to ``centers[i]``."""
72
+ n = len(centers)
73
+ out = np.zeros(n, dtype=np.int64)
74
+ if n == 0 or len(ref) == 0:
75
+ return out
76
+ radii = np.asarray(radii, dtype=float)
77
+ if _use_scipy:
78
+ # query_ball_point uses a closed ball; shrink by one ulp for a strict test.
79
+ r = np.nextafter(radii, -np.inf)
80
+ r = np.where(radii > 0, r, -1.0)
81
+ pos = r >= 0
82
+ if np.any(pos):
83
+ lens = _tree(ref).query_ball_point(centers[pos], r[pos], return_length=True)
84
+ out[pos] = np.asarray(lens, dtype=np.int64)
85
+ return out
86
+ step = _step(len(ref))
87
+ for s in range(0, n, step):
88
+ d = _dist_block(centers[s : s + step], ref)
89
+ out[s : s + step] = np.sum(d < radii[s : s + step, None], axis=1)
90
+ return out
91
+
92
+
93
+ def kth_nearest_distance(ref: np.ndarray, query: np.ndarray, k: int) -> np.ndarray:
94
+ """Distance from each query point to its ``k``-th nearest reference point (1-based).
95
+
96
+ Reference points at distance zero (e.g. the query location itself) count.
97
+ Returns ``inf`` where fewer than ``k`` reference points exist.
98
+ """
99
+ if k < 1:
100
+ raise ValueError("k must be >= 1")
101
+ n = len(query)
102
+ if len(ref) < k:
103
+ return np.full(n, np.inf)
104
+ if _use_scipy:
105
+ d, _ = _tree(ref).query(query, k=[k])
106
+ return np.asarray(d, dtype=float).reshape(n)
107
+ out = np.empty(n)
108
+ step = _step(len(ref))
109
+ for s in range(0, n, step):
110
+ d = _dist_block(query[s : s + step], ref)
111
+ out[s : s + step] = np.partition(d, k - 1, axis=1)[:, k - 1]
112
+ return out
113
+
114
+
115
+ def nearest(ref: np.ndarray, query: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
116
+ """Distance to and index of the nearest reference point for each query point."""
117
+ n = len(query)
118
+ if len(ref) == 0:
119
+ raise ValueError("reference set is empty")
120
+ if _use_scipy:
121
+ d, i = _tree(ref).query(query, k=1)
122
+ return np.asarray(d, dtype=float).reshape(n), np.asarray(i, dtype=np.int64).reshape(n)
123
+ dist = np.empty(n)
124
+ idx = np.empty(n, dtype=np.int64)
125
+ step = _step(len(ref))
126
+ for s in range(0, n, step):
127
+ d = _dist_block(query[s : s + step], ref)
128
+ j = np.argmin(d, axis=1)
129
+ idx[s : s + step] = j
130
+ dist[s : s + step] = d[np.arange(len(j)), j]
131
+ return dist, idx
132
+
133
+
134
+ def within_annulus(
135
+ ref: np.ndarray, query: np.ndarray, r_min: float, r_max: float
136
+ ) -> list[np.ndarray]:
137
+ """Indices of reference points with ``r_min < distance <= r_max`` for each query point."""
138
+ out: list[np.ndarray] = []
139
+ if len(ref) == 0:
140
+ return [np.empty(0, dtype=np.int64) for _ in range(len(query))]
141
+ if _use_scipy:
142
+ tree = _tree(ref)
143
+ lists = tree.query_ball_point(query, r_max)
144
+ for q, idx in zip(query, lists):
145
+ idx = np.asarray(idx, dtype=np.int64)
146
+ if len(idx):
147
+ d = np.hypot(ref[idx, 0] - q[0], ref[idx, 1] - q[1])
148
+ idx = np.sort(idx[d > r_min])
149
+ out.append(idx)
150
+ return out
151
+ step = _step(len(ref))
152
+ for s in range(0, len(query), step):
153
+ d = _dist_block(query[s : s + step], ref)
154
+ mask = (d > r_min) & (d <= r_max)
155
+ out.extend(np.flatnonzero(row) for row in mask)
156
+ return out
157
+
158
+
159
+ def pair_counts(points: np.ndarray, radii: np.ndarray) -> np.ndarray:
160
+ """Number of *ordered* pairs ``(i, j), i != j`` with distance ``<= r`` for each radius."""
161
+ radii = np.asarray(radii, dtype=float)
162
+ n = len(points)
163
+ if n < 2:
164
+ return np.zeros(len(radii), dtype=np.int64)
165
+ if _use_scipy:
166
+ tree = _tree(points)
167
+ c = np.asarray(tree.count_neighbors(tree, radii), dtype=np.int64)
168
+ # count_neighbors includes the n self-pairs at distance 0.
169
+ return c - n
170
+ order = np.argsort(radii)
171
+ counts = np.zeros(len(radii), dtype=np.int64)
172
+ step = _step(n)
173
+ sorted_r = radii[order]
174
+ for s in range(0, n, step):
175
+ d = _dist_block(points[s : s + step], points).ravel()
176
+ d = np.sort(d)
177
+ counts[order] += np.searchsorted(d, sorted_r, side="right")
178
+ return counts - n
geomask/_utils.py ADDED
@@ -0,0 +1,77 @@
1
+ """Small shared helpers: input validation and random-number generators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Union
6
+
7
+ import numpy as np
8
+
9
+ #: Anything accepted as a seed: ``None`` (fresh entropy), an ``int`` or an existing
10
+ #: :class:`numpy.random.Generator` (consumed in place).
11
+ SeedLike = Union[None, int, np.random.Generator]
12
+
13
+
14
+ def as_points(points: object, name: str = "points", allow_nan: bool = False) -> np.ndarray:
15
+ """Return ``points`` as a float ``(n, 2)`` array, validating shape and values."""
16
+ arr = np.asarray(points, dtype=float)
17
+ if arr.ndim == 1 and arr.size == 2:
18
+ arr = arr.reshape(1, 2)
19
+ if arr.ndim == 1 and arr.size == 0:
20
+ arr = arr.reshape(0, 2)
21
+ if arr.ndim != 2 or arr.shape[1] != 2:
22
+ raise ValueError(f"{name} must have shape (n, 2); got {arr.shape}")
23
+ if allow_nan:
24
+ if np.any(np.isinf(arr)):
25
+ raise ValueError(f"{name} contains infinite coordinates")
26
+ elif not np.all(np.isfinite(arr)):
27
+ raise ValueError(f"{name} contains NaN or infinite coordinates")
28
+ return arr
29
+
30
+
31
+ def make_rng(seed: SeedLike) -> np.random.Generator:
32
+ """Return a :class:`numpy.random.Generator` for ``seed``."""
33
+ if isinstance(seed, np.random.Generator):
34
+ return seed
35
+ return np.random.default_rng(seed)
36
+
37
+
38
+ def check_positive(value: float, name: str, allow_zero: bool = False) -> float:
39
+ """Validate a finite, positive (or non-negative) scalar."""
40
+ v = float(value)
41
+ if not np.isfinite(v) or v < 0 or (v == 0 and not allow_zero):
42
+ cond = ">= 0" if allow_zero else "> 0"
43
+ raise ValueError(f"{name} must be finite and {cond}; got {value!r}")
44
+ return v
45
+
46
+
47
+ def check_k(k: int, name: str = "k") -> int:
48
+ """Validate a positive integer threshold."""
49
+ if isinstance(k, bool) or int(k) != k or k < 1:
50
+ raise ValueError(f"{name} must be an integer >= 1; got {k!r}")
51
+ return int(k)
52
+
53
+
54
+ def random_directions(rng: np.random.Generator, n: int) -> np.ndarray:
55
+ """``n`` unit vectors with uniformly distributed angle, shape ``(n, 2)``."""
56
+ theta = rng.uniform(0.0, 2.0 * np.pi, n)
57
+ return np.column_stack([np.cos(theta), np.sin(theta)])
58
+
59
+
60
+ def annulus_radii(
61
+ rng: np.random.Generator,
62
+ r_min: np.ndarray,
63
+ r_max: np.ndarray,
64
+ distribution: str = "area",
65
+ ) -> np.ndarray:
66
+ """Random distances in ``[r_min, r_max]``.
67
+
68
+ ``"area"`` makes the displaced point uniform over the annulus area
69
+ (density of the distance proportional to ``r``); ``"distance"`` makes the
70
+ distance itself uniform (the original donut formulation).
71
+ """
72
+ u = rng.uniform(0.0, 1.0, np.shape(r_min))
73
+ if distribution == "area":
74
+ return np.sqrt(r_min**2 + u * (r_max**2 - r_min**2))
75
+ if distribution == "distance":
76
+ return r_min + u * (r_max - r_min)
77
+ raise ValueError(f"distribution must be 'area' or 'distance'; got {distribution!r}")
geomask/aae.py ADDED
@@ -0,0 +1,214 @@
1
+ """Adaptive areal elimination (AAE) after Kounadi & Leitner (2016).
2
+
3
+ AAE builds areas that each hold at least ``k`` people by merging small base
4
+ units with their neighbours, then moves every point to a random location
5
+ inside the merged area that contains it. Because each release area holds at
6
+ least ``k`` population units, an adversary who knows the area learns only
7
+ that the person is one of at least ``k`` residents (*areal* k-anonymity).
8
+
9
+ This implementation uses a regular square grid as base units, so it needs
10
+ nothing beyond NumPy. Pass address points, building centroids or the centres
11
+ of a population raster (with ``population_weights``) as ``population``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import heapq
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from ._utils import SeedLike, as_points, check_k, check_positive, make_rng
22
+
23
+ __all__ = ["AAEResult", "adaptive_areal_elimination"]
24
+
25
+
26
+ @dataclass
27
+ class AAEResult:
28
+ """Detailed output of :func:`adaptive_areal_elimination`.
29
+
30
+ Attributes:
31
+ points: masked coordinates, shape ``(n, 2)``.
32
+ region_of_point: release-area index of each input point.
33
+ region_population: population of each release area (``>= k`` for every
34
+ area that contains a point).
35
+ region_cells: number of base grid cells merged into each release area.
36
+ cell_region: release-area index of every base grid cell, shape
37
+ ``(ny, nx)`` (row 0 is the southernmost row).
38
+ origin: lower-left corner of the base grid.
39
+ cell_size: base grid cell size in metres.
40
+ k: anonymity threshold.
41
+ """
42
+
43
+ points: np.ndarray
44
+ region_of_point: np.ndarray
45
+ region_population: np.ndarray
46
+ region_cells: np.ndarray
47
+ cell_region: np.ndarray
48
+ origin: np.ndarray
49
+ cell_size: float
50
+ k: int
51
+
52
+ def region_of(self, xy: object) -> np.ndarray:
53
+ """Release-area index for arbitrary coordinates (``-1`` outside the grid)."""
54
+ p = as_points(xy, "xy")
55
+ ny, nx = self.cell_region.shape
56
+ ij = np.floor((p - self.origin) / self.cell_size).astype(np.int64)
57
+ inside = (ij[:, 0] >= 0) & (ij[:, 0] < nx) & (ij[:, 1] >= 0) & (ij[:, 1] < ny)
58
+ out = np.full(len(p), -1, dtype=np.int64)
59
+ out[inside] = self.cell_region[ij[inside, 1], ij[inside, 0]]
60
+ return out
61
+
62
+
63
+ def adaptive_areal_elimination(
64
+ points: object,
65
+ population: object,
66
+ k: int,
67
+ cell_size: float,
68
+ seed: SeedLike = None,
69
+ *,
70
+ population_weights: object | None = None,
71
+ placement: str = "uniform",
72
+ return_details: bool = False,
73
+ ) -> np.ndarray | AAEResult:
74
+ """Mask points with adaptive areal elimination (AAE).
75
+
76
+ Algorithm:
77
+
78
+ 1. Lay a square grid of ``cell_size`` metres over the points and the
79
+ population and count the population in each cell.
80
+ 2. While some area that contains a point has fewer than ``k`` people,
81
+ take the one with the smallest population and merge it with the
82
+ edge-adjacent area of smallest population.
83
+ 3. Move every point to a random location in its merged area:
84
+ ``placement="uniform"`` picks a uniform location over the area;
85
+ ``"population"`` picks a random population unit in the area
86
+ (weighted), which keeps points on plausible residential locations.
87
+
88
+ Args:
89
+ points: ``(n, 2)`` true locations (metres).
90
+ population: ``(m, 2)`` population unit locations.
91
+ k: minimum population per release area.
92
+ cell_size: base grid resolution in metres. Smaller cells give more
93
+ compact, data-adaptive areas at a higher computing cost.
94
+ seed: random seed or generator.
95
+ population_weights: optional people per population unit (default 1).
96
+ placement: ``"uniform"`` or ``"population"``.
97
+ return_details: return an :class:`AAEResult` instead of an array.
98
+
99
+ Raises:
100
+ ValueError: if the total population is below ``k``.
101
+ """
102
+ pts = as_points(points)
103
+ pop = as_points(population, "population")
104
+ k = check_k(k)
105
+ cs = check_positive(cell_size, "cell_size")
106
+ if placement not in {"uniform", "population"}:
107
+ raise ValueError("placement must be 'uniform' or 'population'")
108
+ if population_weights is None:
109
+ w = np.ones(len(pop))
110
+ else:
111
+ w = np.asarray(population_weights, dtype=float).reshape(-1)
112
+ if w.shape != (len(pop),) or np.any(w < 0) or not np.all(np.isfinite(w)):
113
+ raise ValueError("population_weights must be finite, >= 0, one per unit")
114
+ if w.sum() < k:
115
+ raise ValueError(f"total population {w.sum():g} is below k={k}")
116
+ rng = make_rng(seed)
117
+
118
+ allpts = np.vstack([pts, pop])
119
+ origin = np.floor(allpts.min(axis=0) / cs) * cs
120
+ nx, ny = (np.floor((allpts.max(axis=0) - origin) / cs).astype(np.int64) + 1).tolist()
121
+ ncell = nx * ny
122
+
123
+ def cell_index(p: np.ndarray) -> np.ndarray:
124
+ ij = np.floor((p - origin) / cs).astype(np.int64)
125
+ ij[:, 0] = np.clip(ij[:, 0], 0, nx - 1)
126
+ ij[:, 1] = np.clip(ij[:, 1], 0, ny - 1)
127
+ return np.asarray(ij[:, 1] * nx + ij[:, 0], dtype=np.int64)
128
+
129
+ pt_cell = cell_index(pts)
130
+ pop_cell = cell_index(pop)
131
+ cell_pop = np.bincount(pop_cell, weights=w, minlength=ncell)
132
+ has_pt = np.bincount(pt_cell, minlength=ncell) > 0
133
+
134
+ parent = np.arange(ncell)
135
+ rpop: dict[int, float] = {c: float(cell_pop[c]) for c in range(ncell)}
136
+ rhas: dict[int, bool] = {c: bool(has_pt[c]) for c in range(ncell)}
137
+ nbrs: dict[int, set[int]] = {}
138
+ for c in range(ncell):
139
+ cx, cy = c % nx, c // nx
140
+ s: set[int] = set()
141
+ if cx > 0:
142
+ s.add(c - 1)
143
+ if cx < nx - 1:
144
+ s.add(c + 1)
145
+ if cy > 0:
146
+ s.add(c - nx)
147
+ if cy < ny - 1:
148
+ s.add(c + nx)
149
+ nbrs[c] = s
150
+
151
+ heap = [(rpop[c], c) for c in range(ncell) if rhas[c] and rpop[c] < k]
152
+ heapq.heapify(heap)
153
+ while heap:
154
+ p_a, a = heapq.heappop(heap)
155
+ if a not in rpop or rpop[a] != p_a or rpop[a] >= k:
156
+ continue
157
+ # Total population >= k guarantees a neighbour exists while rpop[a] < k.
158
+ b = min(nbrs[a], key=lambda r: (rpop[r], r))
159
+ # Merge b into a.
160
+ parent[b] = a
161
+ rpop[a] += rpop.pop(b)
162
+ rhas[a] = rhas[a] or rhas.pop(b)
163
+ for nb in nbrs.pop(b):
164
+ nbrs[nb].discard(b)
165
+ if nb != a:
166
+ nbrs[nb].add(a)
167
+ nbrs[a].add(nb)
168
+ nbrs[a].discard(b)
169
+ nbrs[a].discard(a)
170
+ if rpop[a] < k:
171
+ heapq.heappush(heap, (rpop[a], a))
172
+
173
+ # Resolve union-find roots (path compression by repeated lookup).
174
+ root = parent.copy()
175
+ while True:
176
+ nxt = root[root]
177
+ if np.array_equal(nxt, root):
178
+ break
179
+ root = nxt
180
+ roots, cell_region = np.unique(root, return_inverse=True)
181
+ cell_region = cell_region.reshape(-1)
182
+ n_reg = len(roots)
183
+ region_population = np.bincount(cell_region, weights=cell_pop, minlength=n_reg)
184
+ region_cells = np.bincount(cell_region, minlength=n_reg)
185
+ region_of_point = cell_region[pt_cell]
186
+
187
+ out = np.empty_like(pts)
188
+ order = np.argsort(cell_region, kind="stable")
189
+ starts = np.concatenate([[0], np.cumsum(region_cells)])
190
+ pop_region = cell_region[pop_cell]
191
+ for i in range(len(pts)):
192
+ reg = region_of_point[i]
193
+ if placement == "uniform":
194
+ cells = order[starts[reg] : starts[reg + 1]]
195
+ c = cells[rng.integers(len(cells))]
196
+ ll = origin + cs * np.array([c % nx, c // nx], dtype=float)
197
+ out[i] = ll + rng.uniform(0.0, cs, 2)
198
+ else:
199
+ cand = np.flatnonzero((pop_region == reg) & (w > 0))
200
+ pr = w[cand] / w[cand].sum()
201
+ out[i] = pop[cand[rng.choice(len(cand), p=pr)]]
202
+
203
+ if return_details:
204
+ return AAEResult(
205
+ points=out,
206
+ region_of_point=region_of_point,
207
+ region_population=region_population,
208
+ region_cells=region_cells,
209
+ cell_region=cell_region.reshape(ny, nx),
210
+ origin=origin,
211
+ cell_size=cs,
212
+ k=k,
213
+ )
214
+ return out