equipop 1.1.0__tar.gz

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.
equipop-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Östh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
equipop-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: equipop
3
+ Version: 1.1.0
4
+ Summary: k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal data: bespoke neighbourhoods, friction growth, distance decay, statistics and segregation measures
5
+ Author: John Östh
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/GeoJohnSwe/EquiPop
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pandas>=2.0
12
+ Requires-Dist: numpy>=1.24
13
+ Requires-Dist: scipy>=1.10
14
+ Requires-Dist: pyproj>=3.4
15
+ Provides-Extra: geo
16
+ Requires-Dist: geopandas>=0.14; extra == "geo"
17
+ Requires-Dist: pyogrio; extra == "geo"
18
+ Requires-Dist: rasterio; extra == "geo"
19
+ Provides-Extra: io
20
+ Requires-Dist: openpyxl; extra == "io"
21
+ Requires-Dist: pyarrow; extra == "io"
22
+ Requires-Dist: pyreadstat; extra == "io"
23
+ Provides-Extra: viz
24
+ Requires-Dist: matplotlib; extra == "viz"
25
+ Requires-Dist: jenkspy; extra == "viz"
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest; extra == "test"
28
+ Requires-Dist: openpyxl; extra == "test"
29
+ Dynamic: license-file
30
+
31
+ # EquiPop (Python)
32
+
33
+ k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal
34
+ data: bespoke neighbourhoods, friction-aware growth, distance decay,
35
+ neighbourhood statistics and segregation measures - for very large
36
+ datasets.
37
+
38
+ EquiPop finds, for every populated location, the k nearest individuals
39
+ (by cumulative population count, expanding over a uniform grid) and
40
+ reports the composition of that individualised neighbourhood. The
41
+ gridded design means one pre-computed distance ordering serves every
42
+ origin, which is what makes millions of locations feasible.
43
+
44
+ ## The EquiPop family
45
+ This is the first Python implementation. Its relatives: the original
46
+ **C# EquiPop** (three generations, two public releases; radial and Flow
47
+ models, downloadable via Uppsala University) and an **R version**
48
+ covering I/O and the basic k-NN parts. This Python version additionally
49
+ carries friction growth, five decay models, exact neighbourhood
50
+ statistics (mean/median/SD/SE/Gini/entropy), segregation indices,
51
+ hexagonal grids, area aggregation and metadata logging.
52
+
53
+ ## Install
54
+ ```
55
+ pip install equipop # core
56
+ pip install equipop[geo,io,viz] # shapefiles/rasters, Excel/SPSS, maps
57
+ ```
58
+
59
+ ## Quick start
60
+ ```python
61
+ import pandas as pd
62
+ from equipop import build_cells, run_knn_stats
63
+
64
+ df = pd.read_csv("individuals.csv") # one row per individual
65
+ cd = build_cells(df, e_col="X", n_col="Y",
66
+ binary_vars=["treated"], value_vars=["income"],
67
+ unit_size=100)
68
+ res = run_knn_stats(cd, k_values=[100, 400, 1600, 6400],
69
+ stats={"treated": ["ratio"],
70
+ "income": ["mean", "median", "gini"]})
71
+ ```
72
+ For aggregated counts at national scale use the vectorised
73
+ `run_knn_counts`; for friction-constrained growth `run_knn_friction`;
74
+ for segregation profiles `seg_profile`; for policy-area reporting
75
+ `aggregate_output`; for quick maps `map_output`. See MANUAL_TOPICS.md.
76
+
77
+ Repository: https://github.com/GeoJohnSwe/EquiPop
78
+
79
+ ## Citing
80
+ Please cite the software and the methods you use - see CITATION.cff.
81
+ Core reference: Östh (2014), *Introducing the EquiPop software*,
82
+ Uppsala University. Segregation measures: Östh, Clark & Malmberg
83
+ (2015), *Geographical Analysis* 47(1). Decay parameters: Östh, Lyhagen
84
+ & Reggiani (2016), *EJTIR* 16(2). Friction growth: Östh & Türk (2020),
85
+ *Handbook of Urban Segregation*. Decay profiles in application:
86
+ Türk, Östh, Kourtit & Nijkamp (2026), *Journal of Urban Mobility* 9.
87
+
88
+ ## License
89
+ MIT. Attribution through citation is warmly requested (see above).
@@ -0,0 +1,59 @@
1
+ # EquiPop (Python)
2
+
3
+ k-nearest neighbour contexts/neighbourhoods on gridded and hexagonal
4
+ data: bespoke neighbourhoods, friction-aware growth, distance decay,
5
+ neighbourhood statistics and segregation measures - for very large
6
+ datasets.
7
+
8
+ EquiPop finds, for every populated location, the k nearest individuals
9
+ (by cumulative population count, expanding over a uniform grid) and
10
+ reports the composition of that individualised neighbourhood. The
11
+ gridded design means one pre-computed distance ordering serves every
12
+ origin, which is what makes millions of locations feasible.
13
+
14
+ ## The EquiPop family
15
+ This is the first Python implementation. Its relatives: the original
16
+ **C# EquiPop** (three generations, two public releases; radial and Flow
17
+ models, downloadable via Uppsala University) and an **R version**
18
+ covering I/O and the basic k-NN parts. This Python version additionally
19
+ carries friction growth, five decay models, exact neighbourhood
20
+ statistics (mean/median/SD/SE/Gini/entropy), segregation indices,
21
+ hexagonal grids, area aggregation and metadata logging.
22
+
23
+ ## Install
24
+ ```
25
+ pip install equipop # core
26
+ pip install equipop[geo,io,viz] # shapefiles/rasters, Excel/SPSS, maps
27
+ ```
28
+
29
+ ## Quick start
30
+ ```python
31
+ import pandas as pd
32
+ from equipop import build_cells, run_knn_stats
33
+
34
+ df = pd.read_csv("individuals.csv") # one row per individual
35
+ cd = build_cells(df, e_col="X", n_col="Y",
36
+ binary_vars=["treated"], value_vars=["income"],
37
+ unit_size=100)
38
+ res = run_knn_stats(cd, k_values=[100, 400, 1600, 6400],
39
+ stats={"treated": ["ratio"],
40
+ "income": ["mean", "median", "gini"]})
41
+ ```
42
+ For aggregated counts at national scale use the vectorised
43
+ `run_knn_counts`; for friction-constrained growth `run_knn_friction`;
44
+ for segregation profiles `seg_profile`; for policy-area reporting
45
+ `aggregate_output`; for quick maps `map_output`. See MANUAL_TOPICS.md.
46
+
47
+ Repository: https://github.com/GeoJohnSwe/EquiPop
48
+
49
+ ## Citing
50
+ Please cite the software and the methods you use - see CITATION.cff.
51
+ Core reference: Östh (2014), *Introducing the EquiPop software*,
52
+ Uppsala University. Segregation measures: Östh, Clark & Malmberg
53
+ (2015), *Geographical Analysis* 47(1). Decay parameters: Östh, Lyhagen
54
+ & Reggiani (2016), *EJTIR* 16(2). Friction growth: Östh & Türk (2020),
55
+ *Handbook of Urban Segregation*. Decay profiles in application:
56
+ Türk, Östh, Kourtit & Nijkamp (2026), *Journal of Urban Mobility* 9.
57
+
58
+ ## License
59
+ MIT. Attribution through citation is warmly requested (see above).
@@ -0,0 +1,31 @@
1
+ """
2
+ EquiPop Pangea - k-nearest neighbour contextual analysis on gridded data.
3
+
4
+ Phase 1, Step 1: projection, grid snapping, and the basic radial k-NN engine
5
+ (no friction, no decay yet - those come in Phase 2).
6
+ """
7
+
8
+ from .transform import project_to_metric, snap_to_grid
9
+ from .analysis import run_knn
10
+ from .decay import Decay
11
+ from .analysis import run_knn_stats
12
+ from .cells import build_cells, CellData
13
+ from .friction import run_knn_friction, load_friction_table
14
+ from .projection import suggest_projection, assign_zones
15
+ from .io import read_table, save_output
16
+ from .fetch import fetch
17
+ from .hex import build_hex_cells
18
+ from .meta import RunLog, load_meta
19
+ from .io import list_layers
20
+ from .fastcounts import run_knn_counts
21
+ from .segregation import seg_profile
22
+ from .area import aggregate_output
23
+ try:
24
+ from .viz import map_output
25
+ except ImportError: # matplotlib is an optional extra
26
+ def map_output(*a, **k):
27
+ raise ImportError("map_output needs matplotlib: "
28
+ "pip install equipop[viz]")
29
+
30
+ __version__ = "1.1.0"
31
+ __all__ = ["project_to_metric", "snap_to_grid", "run_knn", "Decay", "run_knn_stats", "build_cells", "CellData", "run_knn_friction", "load_friction_table", "suggest_projection", "assign_zones", "read_table", "save_output", "fetch", "build_hex_cells", "RunLog", "load_meta", "list_layers", "run_knn_counts", "seg_profile", "aggregate_output", "map_output"]
@@ -0,0 +1,369 @@
1
+ """
2
+ analysis.py - the radial k-NN engine (Phase 1 + decay).
3
+
4
+ Core idea (from the EquiPop papers): on a uniform grid, the relative
5
+ distances from ANY origin cell to its surrounding cells are always the
6
+ same. So we compute ONE list of cell-offsets sorted by distance, and
7
+ reuse it for every origin. Cells at identical distance form a "ring".
8
+
9
+ Decay: when a Decay object is passed, every neighbour's contribution
10
+ is ALSO accumulated multiplied by weight(distance). The k-thresholds
11
+ are still defined by the RAW (unweighted) counts - the decayed values
12
+ are simply recorded at the same moment, exactly as in the original
13
+ EquiPop ("decayed variables use the same k-values as the non-decaying
14
+ variables"). Decayed counts are therefore always <= raw counts.
15
+
16
+ Output naming - two schemes, chosen with naming="short" | "legacy":
17
+
18
+ short (default) legacy (original EquiPop)
19
+ --------------- -------------------------------
20
+ N_50 IntervalSumCountAll_50
21
+ T_50 IntervalSumCountGroup_50
22
+ R_50 IntervalRatio_50
23
+ Dist_50 IntervalDistance_50
24
+ ND_50 IntervalSumCountAllDecay_50
25
+ TD_50 IntervalSumCountGroupDecay_50
26
+ RD_50 IntervalRatioDecay_50
27
+
28
+ (N = count of all, T = treatment, R = ratio, D = decayed.)
29
+ """
30
+
31
+ import math
32
+ import numpy as np
33
+ import pandas as pd
34
+ from itertools import groupby
35
+
36
+ from .decay import Decay
37
+
38
+
39
+ # ---------------------------------------------------------------- naming
40
+ NAMES = {
41
+ "short": {
42
+ "N": "N_{k}", "T": "T_{k}", "R": "R_{k}", "Dist": "Dist_{k}",
43
+ "ND": "ND_{k}", "TD": "TD_{k}", "RD": "RD_{k}",
44
+ },
45
+ "legacy": {
46
+ "N": "IntervalSumCountAll_{k}",
47
+ "T": "IntervalSumCountGroup_{k}",
48
+ "R": "IntervalRatio_{k}",
49
+ "Dist": "IntervalDistance_{k}",
50
+ "ND": "IntervalSumCountAllDecay_{k}",
51
+ "TD": "IntervalSumCountGroupDecay_{k}",
52
+ "RD": "IntervalRatioDecay_{k}",
53
+ },
54
+ }
55
+
56
+
57
+ def build_distance_rings(max_radius_units: int):
58
+ """
59
+ Pre-compute all cell offsets (dx, dy) within max_radius_units,
60
+ grouped into 'rings' of identical distance, sorted by distance.
61
+ The origin (0, 0) is excluded - it is counted first, separately.
62
+
63
+ Returns a list of (distance_in_units, [(dx, dy), ...]).
64
+ """
65
+ offsets = []
66
+ for dx in range(-max_radius_units, max_radius_units + 1):
67
+ for dy in range(-max_radius_units, max_radius_units + 1):
68
+ if dx == 0 and dy == 0:
69
+ continue
70
+ d = math.hypot(dx, dy)
71
+ if d <= max_radius_units:
72
+ offsets.append((d, dx, dy))
73
+ offsets.sort(key=lambda t: t[0])
74
+ return [(dist, [(dx, dy) for _, dx, dy in grp])
75
+ for dist, grp in groupby(offsets, key=lambda t: t[0])]
76
+
77
+
78
+ def run_knn(
79
+ cells: pd.DataFrame,
80
+ k_values: list[int],
81
+ count_all_col: str = "FullPop",
82
+ count_group_col: str = "Treatment",
83
+ unit_size: float = 100.0,
84
+ max_radius_units: int = 500,
85
+ id_col: str | None = "id",
86
+ tie_mode: str = "ring",
87
+ decay: Decay | None = None,
88
+ naming: str = "short",
89
+ seed: int | None = None,
90
+ ) -> pd.DataFrame:
91
+ """
92
+ Radial k-NN analysis for every populated cell.
93
+
94
+ Parameters
95
+ ----------
96
+ cells : DataFrame with one row per grid cell ('E_grid', 'N_grid',
97
+ a total-count column, a treatment-count column).
98
+ k_values : the k thresholds, e.g. [50, 100, 200, 400, 800].
99
+ unit_size : grid size in metres.
100
+ max_radius_units : search limit in grid units; unreached ks get
101
+ partial results (mirrors original EquiPop behaviour).
102
+ tie_mode : "ring" (default) adds all equidistant cells before
103
+ checking thresholds; "sequential" checks after every
104
+ single cell (original EquiPop, order-dependent).
105
+ decay : a Decay object, e.g. Decay(half_life_m=8000), or None.
106
+ naming : "short" (N_50, T_50, R_50, ...) or
107
+ "legacy" (IntervalSumCountAll_50, ...).
108
+ seed : only used with tie_mode="sequential": shuffles the (otherwise
109
+ arbitrary) within-ring visiting order reproducibly. Record it
110
+ in the run's metadata log. None keeps the construction order.
111
+
112
+ Returns
113
+ -------
114
+ One row per origin cell. Fixed columns: Id, EastWest, NorthSouth,
115
+ CountAllLocal, CountGroupLocal, SumCountAll, SumCountGroup, Ratio,
116
+ MaxDistance. Per-k columns as per the chosen naming scheme.
117
+ """
118
+ k_values = sorted(k_values)
119
+ nm = NAMES[naming]
120
+
121
+ def col(kind: str, k: int) -> str:
122
+ return nm[kind].format(k=k)
123
+
124
+ # ---- fast lookup: (E, N) -> (count_all, count_group) ----
125
+ lookup: dict[tuple[int, int], tuple[float, float]] = {}
126
+ for row in cells.itertuples(index=False):
127
+ key = (int(getattr(row, "E_grid")), int(getattr(row, "N_grid")))
128
+ lookup[key] = (float(getattr(row, count_all_col)),
129
+ float(getattr(row, count_group_col)))
130
+
131
+ print(f"[analysis] {len(lookup)} populated cells, k = {k_values}, "
132
+ f"unit = {unit_size} m")
133
+ if decay:
134
+ print(f"[analysis] decay active: {decay.describe()}")
135
+ rings = build_distance_rings(max_radius_units)
136
+ if tie_mode == "sequential" and seed is not None:
137
+ rng = np.random.default_rng(seed)
138
+ rings = [(d, list(rng.permutation(np.array(offs, dtype=object))))
139
+ for d, offs in rings]
140
+ print(f"[analysis] sequential tie order shuffled with seed {seed}")
141
+ print(f"[analysis] {len(rings)} distance rings ready")
142
+
143
+ results = []
144
+ step = int(unit_size)
145
+
146
+ for row in cells.itertuples(index=False):
147
+ e0 = int(getattr(row, "E_grid"))
148
+ n0 = int(getattr(row, "N_grid"))
149
+ local_all, local_grp = lookup[(e0, n0)]
150
+
151
+ rec: dict = {
152
+ "Id": getattr(row, id_col) if id_col else None,
153
+ "EastWest": e0,
154
+ "NorthSouth": n0,
155
+ "CountAllLocal": local_all,
156
+ "CountGroupLocal": local_grp,
157
+ }
158
+
159
+ # running totals - raw and (optionally) decay-weighted
160
+ sum_all, sum_grp, dist_m = local_all, local_grp, 0.0
161
+ d_all, d_grp = local_all, local_grp # weight(0) = 1
162
+ pending = list(k_values)
163
+
164
+ def record(k: int):
165
+ """Write all per-k output columns at the current state."""
166
+ rec[col("N", k)] = sum_all
167
+ rec[col("T", k)] = sum_grp
168
+ rec[col("R", k)] = sum_grp / sum_all if sum_all else np.nan
169
+ rec[col("Dist", k)] = dist_m
170
+ if decay:
171
+ rec[col("ND", k)] = d_all
172
+ rec[col("TD", k)] = d_grp
173
+ rec[col("RD", k)] = d_grp / d_all if d_all else np.nan
174
+
175
+ # thresholds already satisfied inside the origin cell
176
+ while pending and sum_all >= pending[0]:
177
+ record(pending.pop(0))
178
+
179
+ # --- expand ring by ring ---
180
+ for dist_units, offsets in rings:
181
+ if not pending:
182
+ break
183
+ ring_dist_m = dist_units * unit_size
184
+ w = decay.weight(ring_dist_m) if decay else 1.0
185
+
186
+ if tie_mode == "sequential":
187
+ for dx, dy in offsets:
188
+ cell = lookup.get((e0 + dx * step, n0 + dy * step))
189
+ if not cell:
190
+ continue
191
+ sum_all += cell[0]
192
+ sum_grp += cell[1]
193
+ d_all += cell[0] * w
194
+ d_grp += cell[1] * w
195
+ dist_m = ring_dist_m
196
+ while pending and sum_all >= pending[0]:
197
+ record(pending.pop(0))
198
+ else: # "ring" - atomic per equidistant ring
199
+ ring_all = ring_grp = 0.0
200
+ for dx, dy in offsets:
201
+ cell = lookup.get((e0 + dx * step, n0 + dy * step))
202
+ if cell:
203
+ ring_all += cell[0]
204
+ ring_grp += cell[1]
205
+ if ring_all == 0:
206
+ continue
207
+ sum_all += ring_all
208
+ sum_grp += ring_grp
209
+ d_all += ring_all * w
210
+ d_grp += ring_grp * w
211
+ dist_m = ring_dist_m
212
+ while pending and sum_all >= pending[0]:
213
+ record(pending.pop(0))
214
+
215
+ # unreached thresholds: partial results (spec, section 12)
216
+ for k in pending:
217
+ record(k)
218
+
219
+ rec["SumCountAll"] = sum_all
220
+ rec["SumCountGroup"] = sum_grp
221
+ rec["Ratio"] = sum_grp / sum_all if sum_all else np.nan
222
+ rec["MaxDistance"] = dist_m
223
+ results.append(rec)
224
+
225
+ out = pd.DataFrame(results)
226
+ fixed = ["Id", "EastWest", "NorthSouth", "CountAllLocal",
227
+ "CountGroupLocal", "SumCountAll", "SumCountGroup",
228
+ "Ratio", "MaxDistance"]
229
+ kinds = ["N", "T", "R", "Dist"] + (["ND", "TD", "RD"] if decay else [])
230
+ per_k = [col(kind, k) for k in k_values for kind in kinds]
231
+ return out[fixed + per_k]
232
+
233
+
234
+ # ======================================================================
235
+ # run_knn_stats - k-NN with per-variable statistics (tiers 1-3)
236
+ # ======================================================================
237
+ from .cells import CellData
238
+ from .stats import BINARY_STATS, VALUE_STATS, PREFIX
239
+
240
+
241
+ def run_knn_stats(
242
+ cd: CellData,
243
+ k_values: list[int],
244
+ stats: dict[str, list[str]],
245
+ max_radius_units: int | None = None,
246
+ ) -> pd.DataFrame:
247
+ """
248
+ Radial k-NN analysis with user-selected statistics per variable.
249
+
250
+ The user switches statistics on per FUNCTION (applied to all k),
251
+ exactly as requested:
252
+
253
+ stats = {
254
+ "HighEdu": ["ratio", "sd", "se", "entropy", "gini"], # binary
255
+ "ForvInk": ["mean", "median", "sd", "se", "gini"], # value
256
+ }
257
+
258
+ Whether a variable is binary (tier 1, exact from counts) or
259
+ continuous (tiers 2/3, from stored individual values) is decided
260
+ by how it was declared in build_cells().
261
+
262
+ Engine note: this function uses a distance-sort core rather than
263
+ the ring-expansion core of run_knn(). For the radial (no-friction)
264
+ model the two are MATHEMATICALLY IDENTICAL - cells at equal
265
+ distance are still processed as one atomic ring - but the sort
266
+ core is much faster when populated cells are sparse. The
267
+ ring-expansion core remains the basis for the future friction
268
+ model, where visiting order genuinely depends on the path.
269
+
270
+ Output columns
271
+ --------------
272
+ EastWest, NorthSouth, N_local, plus per k:
273
+ N_{k}, Dist_{k},
274
+ per binary var+stat: e.g. R_HighEdu_{k}, Gini_HighEdu_{k}
275
+ per value var+stat: e.g. Mean_ForvInk_{k}, Med_ForvInk_{k}
276
+ per value var: Nv_{var}_{k} (count of valid values)
277
+ Unreached k-levels receive partial results, as in run_knn().
278
+ """
279
+ k_values = sorted(k_values)
280
+
281
+ bin_vars = [v for v in stats if v in cd.binary_sums]
282
+ val_vars = [v for v in stats if v in cd.value_arrays]
283
+ unknown = [v for v in stats if v not in bin_vars + val_vars]
284
+ if unknown:
285
+ raise ValueError(f"Variables {unknown} were not declared in "
286
+ f"build_cells(binary_vars=..., value_vars=...).")
287
+ for v in bin_vars:
288
+ for s in stats[v]:
289
+ if s not in BINARY_STATS:
290
+ raise ValueError(f"Unknown binary statistic '{s}' for {v}. "
291
+ f"Available: {list(BINARY_STATS)}")
292
+ for v in val_vars:
293
+ for s in stats[v]:
294
+ if s not in VALUE_STATS:
295
+ raise ValueError(f"Unknown value statistic '{s}' for {v}. "
296
+ f"Available: {list(VALUE_STATS)}")
297
+
298
+ m = len(cd)
299
+ print(f"[stats] {m} cells, k = {k_values}")
300
+ print(f"[stats] binary vars: {bin_vars} | value vars: {val_vars}")
301
+
302
+ results = []
303
+ Ef, Nf = cd.E.astype(float), cd.N.astype(float)
304
+
305
+ for oi in range(m):
306
+ e0, n0 = cd.E[oi], cd.N[oi]
307
+
308
+ # distances from this origin to ALL populated cells (vectorised)
309
+ dist = np.hypot(Ef - e0, Nf - n0)
310
+ order = np.argsort(dist, kind="stable")
311
+
312
+ rec: dict = {"EastWest": round(float(e0), 2),
313
+ "NorthSouth": round(float(n0), 2),
314
+ "N_local": float(cd.n[oi])}
315
+ if cd.labels is not None:
316
+ rec["CellId"] = cd.labels[oi]
317
+ for v in bin_vars:
318
+ rec[f"{v}_local"] = float(cd.binary_sums[v][oi])
319
+
320
+ # running state
321
+ sum_n = 0.0
322
+ bin_t = {v: 0.0 for v in bin_vars}
323
+ val_chunks = {v: [] for v in val_vars}
324
+ dist_m = 0.0
325
+ pending = list(k_values)
326
+
327
+ def record(k: int):
328
+ rec[f"N_{k}"] = sum_n
329
+ rec[f"Dist_{k}"] = dist_m
330
+ for v in bin_vars:
331
+ for s in stats[v]:
332
+ rec[f"{PREFIX[s]}_{v}_{k}"] = BINARY_STATS[s](sum_n, bin_t[v])
333
+ for v in val_vars:
334
+ x = (np.concatenate(val_chunks[v])
335
+ if val_chunks[v] else np.empty(0))
336
+ rec[f"Nv_{v}_{k}"] = len(x)
337
+ for s in stats[v]:
338
+ rec[f"{PREFIX[s]}_{v}_{k}"] = VALUE_STATS[s](x)
339
+
340
+ # walk cells in distance order, atomically per equal-distance ring
341
+ j = 0
342
+ while j < m and pending:
343
+ d = dist[order[j]]
344
+ if max_radius_units is not None and d > max_radius_units * cd.unit_size:
345
+ break
346
+ # gather the full ring of cells at this exact distance
347
+ ring = []
348
+ while j < m and dist[order[j]] - d < 1e-6:
349
+ ring.append(order[j])
350
+ j += 1
351
+ for ci in ring:
352
+ sum_n += float(cd.n[ci])
353
+ for v in bin_vars:
354
+ bin_t[v] += cd.binary_sums[v][ci]
355
+ for v in val_vars:
356
+ a = cd.value_arrays[v][ci]
357
+ if len(a):
358
+ val_chunks[v].append(a)
359
+ dist_m = float(d)
360
+ while pending and sum_n >= pending[0]:
361
+ record(pending.pop(0))
362
+
363
+ for k in pending: # unreached: partial results
364
+ record(k)
365
+ rec["SumN"] = sum_n
366
+ rec["MaxDistance"] = dist_m
367
+ results.append(rec)
368
+
369
+ return pd.DataFrame(results)