equipop 1.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.
equipop/__init__.py ADDED
@@ -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"]
equipop/analysis.py ADDED
@@ -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)
equipop/area.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+ area.py - area-based output (backlog item 9): bring overlapping
3
+ bespoke-neighbourhood results back to fixed geographies that policy
4
+ audiences grasp.
5
+
6
+ DELIBERATE DESIGN, stated for reviewers: values are collected
7
+ overlappingly with k and then SUMMARISED per area - this is
8
+ "individualised context, reported per area", not a recomputation of
9
+ areal statistics. The precedent is the block-level illustration of
10
+ EquiPop output in Östh, Clark & Malmberg (2015, fig. 1).
11
+
12
+ Three alternatives, one function:
13
+ Alt 1 by = a COLUMN NAME already on the output (belonging ID,
14
+ e.g. a municipality code carried through CellId/merge).
15
+ Alt 2 by = a POLYGON FILE path (shp/gpkg): origin cells are
16
+ point-in-polygon assigned (geopandas sjoin); `id_field`
17
+ names the polygon attribute to aggregate by. `points_epsg`
18
+ must be given if it differs from the polygons' CRS.
19
+ Alt 3 by = a NUMBER: a coarser grid size (e.g. 1000 for 1 km
20
+ super-cells over 100 m results), anchored at the minimum
21
+ X/Y of the data.
22
+ """
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+
28
+ def aggregate_output(
29
+ df: pd.DataFrame,
30
+ by,
31
+ columns: list[str] | None = None,
32
+ how: str = "wmean",
33
+ weight_col: str = "N_local",
34
+ id_field: str | None = None,
35
+ points_epsg: int | None = None,
36
+ x_col: str = "EastWest",
37
+ y_col: str = "NorthSouth",
38
+ ) -> pd.DataFrame:
39
+ """
40
+ Aggregate k-NN output to areas. See module docstring for the three
41
+ `by` alternatives.
42
+
43
+ columns : which output columns to aggregate (default: all R_*,
44
+ Mean_*, Med_*, Gini_*, SD_* columns found).
45
+ how : 'wmean' population-weighted mean (weights = weight_col,
46
+ typically the origin population), 'mean', or 'median'.
47
+ Returns one row per area with the aggregated columns, the number
48
+ of origin cells, and the summed weight.
49
+ """
50
+ df = df.copy()
51
+ if columns is None:
52
+ columns = [c for c in df.columns if c.split("_")[0] in
53
+ ("R", "Mean", "Med", "Gini", "SD", "Ratio")]
54
+ if not columns:
55
+ raise ValueError("No aggregatable columns found - pass "
56
+ "columns=[...] explicitly.")
57
+
58
+ # ---- resolve `by` into a per-row area label ----
59
+ if isinstance(by, (int, float)): # Alt 3
60
+ u = float(by)
61
+ x0, y0 = df[x_col].min(), df[y_col].min()
62
+ df["_area"] = (
63
+ "SG" + (np.floor((df[x_col] - x0) / u)).astype(int).astype(str)
64
+ + "_" + (np.floor((df[y_col] - y0) / u)).astype(int).astype(str))
65
+ label = f"supergrid_{int(u)}m"
66
+ elif isinstance(by, str) and by in df.columns: # Alt 1
67
+ df["_area"] = df[by]
68
+ label = by
69
+ elif isinstance(by, str): # Alt 2: file
70
+ try:
71
+ import geopandas as gpd
72
+ except ImportError:
73
+ raise ImportError("Polygon aggregation needs geopandas.")
74
+ polys = gpd.read_file(by)
75
+ if id_field is None:
76
+ raise ValueError("Give id_field=<polygon attribute>.")
77
+ pts = gpd.GeoDataFrame(
78
+ df[[x_col, y_col]],
79
+ geometry=gpd.points_from_xy(df[x_col], df[y_col]),
80
+ crs=f"EPSG:{points_epsg}" if points_epsg else polys.crs)
81
+ if points_epsg and pts.crs != polys.crs:
82
+ pts = pts.to_crs(polys.crs)
83
+ print(f"[area] points reprojected EPSG:{points_epsg} -> "
84
+ f"{polys.crs.to_epsg()} for the join")
85
+ joined = gpd.sjoin(pts, polys[[id_field, "geometry"]],
86
+ how="left", predicate="within")
87
+ df["_area"] = joined[id_field].to_numpy()
88
+ unmatched = df["_area"].isna().sum()
89
+ if unmatched:
90
+ print(f"[area] WARNING: {unmatched} origin cells fall outside "
91
+ f"all polygons (dropped from area output).")
92
+ df = df[df["_area"].notna()]
93
+ label = id_field
94
+ else:
95
+ raise ValueError("`by` must be a column name, a polygon file "
96
+ "path, or a super-grid size in metres.")
97
+
98
+ # ---- aggregate ----
99
+ w = df[weight_col].to_numpy(float)
100
+ out_rows = []
101
+ for area, g in df.groupby("_area"):
102
+ gw = g[weight_col].to_numpy(float)
103
+ row = {label: area, "n_origin_cells": len(g),
104
+ weight_col + "_sum": gw.sum()}
105
+ for c in columns:
106
+ v = g[c].to_numpy(float)
107
+ ok = np.isfinite(v)
108
+ if not ok.any():
109
+ row[c] = np.nan
110
+ elif how == "wmean":
111
+ row[c] = np.average(v[ok], weights=gw[ok]) \
112
+ if gw[ok].sum() > 0 else np.nan
113
+ elif how == "mean":
114
+ row[c] = v[ok].mean()
115
+ elif how == "median":
116
+ row[c] = np.median(v[ok])
117
+ out_rows.append(row)
118
+ out = pd.DataFrame(out_rows)
119
+ print(f"[area] {len(df)} origin cells -> {len(out)} areas "
120
+ f"('{label}', how={how})")
121
+ return out
equipop/cells.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ cells.py - build cell-level data from INDIVIDUAL-level rows.
3
+
4
+ This is the entry point for "tier 3" data: one row per individual,
5
+ where several individuals may share the same coordinate. The builder
6
+ aggregates them into grid cells while keeping, per cell:
7
+
8
+ - n : the individual count (this is what k counts!)
9
+ - binary sums : one running sum per binary variable (0/1)
10
+ - value arrays : the raw individual values per continuous variable
11
+ (needed for exact median / Gini at k-level)
12
+
13
+ Missing handling (spec section 12):
14
+ - rows with missing coordinates are DROPPED with a printed warning
15
+ - missing values in a continuous variable: the individual still
16
+ counts towards k, but contributes no value to that variable's
17
+ statistics (a separate valid-n is reported as Nv_<var>_<k>)
18
+ """
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from dataclasses import dataclass, field
23
+
24
+
25
+ @dataclass
26
+ class CellData:
27
+ """Aggregated per-cell data ready for run_knn_stats()."""
28
+ E: np.ndarray # cell midpoint eastings (m cells)
29
+ N: np.ndarray # cell midpoint northings
30
+ n: np.ndarray # individuals per cell
31
+ binary_sums: dict = field(default_factory=dict) # var -> array (m,)
32
+ value_arrays: dict = field(default_factory=dict) # var -> list of arrays
33
+ unit_size: float = 100.0
34
+ labels: list | None = None # optional per-cell ID/label (e.g. place, year)
35
+
36
+ def __len__(self):
37
+ return len(self.n)
38
+
39
+
40
+ def build_cells(
41
+ df: pd.DataFrame,
42
+ e_col: str,
43
+ n_col: str,
44
+ binary_vars: list[str] | None = None,
45
+ value_vars: list[str] | None = None,
46
+ unit_size: float = 100.0,
47
+ snap: bool = True,
48
+ label_col: str | None = None,
49
+ ) -> CellData:
50
+ """
51
+ Aggregate an individual-level DataFrame into CellData.
52
+
53
+ Parameters
54
+ ----------
55
+ df : one row per individual.
56
+ e_col, n_col : METRIC coordinate columns (already projected).
57
+ binary_vars : 0/1 columns (each becomes a treatment with exact
58
+ count-based statistics).
59
+ value_vars : continuous columns (individual values are stored
60
+ per cell for exact median/Gini/etc.).
61
+ unit_size : grid size in metres.
62
+ snap : snap coordinates to grid midpoints. Idempotent - if the
63
+ data is already midpoint-snapped (like 100m register data
64
+ ending in ...50), snapping changes nothing.
65
+ label_col : optional ID column carried through to the output
66
+ (e.g. a place code or a year). If a cell contains SEVERAL
67
+ distinct labels they are joined with '|' and a warning is
68
+ printed - a good ID should be constant within a cell.
69
+ """
70
+ binary_vars = binary_vars or []
71
+ value_vars = value_vars or []
72
+ df = df.copy()
73
+
74
+ # --- coerce to numeric; blanks become NaN ---
75
+ for c in [e_col, n_col] + binary_vars + value_vars:
76
+ df[c] = pd.to_numeric(df[c], errors="coerce")
77
+
78
+ # --- missing coordinates: drop with warning (spec 12) ---
79
+ bad = df[e_col].isna() | df[n_col].isna()
80
+ if bad.any():
81
+ print(f"[cells] WARNING: {bad.sum()} of {len(df)} rows have "
82
+ f"missing coordinates and are dropped.")
83
+ df = df[~bad]
84
+
85
+ # --- snap to grid midpoints ---
86
+ if snap:
87
+ half = unit_size / 2.0
88
+ df["_E"] = (np.floor(df[e_col] / unit_size) * unit_size + half).astype(int)
89
+ df["_N"] = (np.floor(df[n_col] / unit_size) * unit_size + half).astype(int)
90
+ else:
91
+ df["_E"] = df[e_col].astype(int)
92
+ df["_N"] = df[n_col].astype(int)
93
+
94
+ # --- report missing values in analysis variables ---
95
+ for v in value_vars:
96
+ miss = df[v].isna().sum()
97
+ if miss:
98
+ print(f"[cells] note: '{v}' has {miss} missing values - these "
99
+ f"individuals count towards k but not towards {v} statistics.")
100
+
101
+ # --- aggregate ---
102
+ groups = df.groupby(["_E", "_N"], sort=True)
103
+ E, N, n = [], [], []
104
+ bsums = {v: [] for v in binary_vars}
105
+ varrs = {v: [] for v in value_vars}
106
+ labels = [] if label_col else None
107
+ mixed = 0
108
+
109
+ for (e, nn), g in groups:
110
+ E.append(e)
111
+ N.append(nn)
112
+ n.append(len(g))
113
+ for v in binary_vars:
114
+ bsums[v].append(g[v].sum())
115
+ for v in value_vars:
116
+ varrs[v].append(g[v].dropna().to_numpy(dtype=float))
117
+ if label_col:
118
+ uniq = g[label_col].astype(str).unique()
119
+ if len(uniq) > 1:
120
+ mixed += 1
121
+ labels.append("|".join(sorted(uniq)))
122
+
123
+ if label_col and mixed:
124
+ print(f"[cells] WARNING: {mixed} cells contain several distinct "
125
+ f"'{label_col}' values (joined with '|'). A good cell ID "
126
+ f"should be constant within a cell.")
127
+
128
+ cd = CellData(
129
+ E=np.array(E, dtype=np.int64),
130
+ N=np.array(N, dtype=np.int64),
131
+ n=np.array(n, dtype=np.int64),
132
+ binary_sums={v: np.array(a, dtype=float) for v, a in bsums.items()},
133
+ value_arrays=varrs,
134
+ unit_size=unit_size,
135
+ labels=labels,
136
+ )
137
+ print(f"[cells] {len(df)} individuals -> {len(cd)} cells "
138
+ f"(unit {unit_size} m, global N = {cd.n.sum()})")
139
+ return cd