diffusion-cartogram 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.
@@ -0,0 +1,831 @@
1
+ """
2
+ 2D VDERM core: VDERMGrid2D, grid utilities, I/O, and interpolation.
3
+
4
+ Mirrors the 3D core.py API but operates on a 2D (L × M) grid. The
5
+ density diffusion and node advection physics are identical — just one
6
+ spatial dimension removed — so run_VDERM() from core.py works unchanged
7
+ with VDERMGrid2D objects.
8
+
9
+ For geographic workflows, read_geojson / read_shapefile extract 2-D
10
+ point arrays and density_from_geotiff samples a raster onto the grid.
11
+ """
12
+
13
+ import numpy as np
14
+ from scipy.interpolate import RegularGridInterpolator
15
+ from tqdm import tqdm
16
+ import os
17
+
18
+ try:
19
+ import geopandas as gpd
20
+ HAS_GEOPANDAS = True
21
+ except ImportError:
22
+ HAS_GEOPANDAS = False
23
+ gpd = None
24
+
25
+ try:
26
+ import rasterio
27
+ HAS_RASTERIO = True
28
+ except ImportError:
29
+ HAS_RASTERIO = False
30
+ rasterio = None
31
+
32
+
33
+ def _require_geopandas(fname):
34
+ if not HAS_GEOPANDAS:
35
+ raise ImportError(
36
+ f"{fname} requires geopandas for geographic file I/O.\n"
37
+ "Install with: pip install diffusion-cartogram[2D]"
38
+ )
39
+
40
+
41
+ def _require_rasterio(fname):
42
+ if not HAS_RASTERIO:
43
+ raise ImportError(
44
+ f"{fname} requires rasterio for GeoTIFF support.\n"
45
+ "Install with: pip install diffusion-cartogram[2D]"
46
+ )
47
+
48
+
49
+ # ─── Grid class ──────────────────────────────────────────────────────────────
50
+
51
+ class VDERMGrid2D:
52
+ """
53
+ 2-D Lagrangian-Eulerian grid for VDERM deformation.
54
+
55
+ Grid nodes are both computational points (Eulerian) and material
56
+ points (Lagrangian), mirroring VDERMGrid but for a planar (L × M) domain.
57
+
58
+ Compatible with run_VDERM() from core.py — all required methods are
59
+ present. Use run_VDERM_2d_with_tracking() for intermediate exports.
60
+
61
+ Parameters
62
+ ----------
63
+ shape : tuple (L, M)
64
+ Grid dimensions along x and y axes.
65
+ h : float
66
+ Uniform grid spacing.
67
+ min_bounds : array-like, shape (2,)
68
+ Lower-left corner [x_min, y_min].
69
+ """
70
+
71
+ def __init__(self, shape, h, min_bounds):
72
+ self.L, self.M = shape
73
+ self.h = float(h)
74
+ self.min_bounds = np.asarray(min_bounds, dtype=float)
75
+
76
+ # Eulerian density field
77
+ self.rho = np.ones((self.L, self.M))
78
+
79
+ # Lagrangian node positions and velocities — shape (L*M, 2)
80
+ self.positions = self._initialize_positions()
81
+ self.velocities = np.zeros_like(self.positions)
82
+ self.initial_positions = self.positions.copy()
83
+
84
+ self.epsilon = None
85
+
86
+ # ── initialisation ───────────────────────────────────────────────────────
87
+
88
+ def _initialize_positions(self):
89
+ i_idx = np.arange(self.L)
90
+ j_idx = np.arange(self.M)
91
+ ii, jj = np.meshgrid(i_idx, j_idx, indexing='ij')
92
+ return np.stack(
93
+ [self.min_bounds[0] + self.h * ii,
94
+ self.min_bounds[1] + self.h * jj],
95
+ axis=-1
96
+ ).reshape(-1, 2)
97
+
98
+ # ── index helpers ─────────────────────────────────────────────────────────
99
+
100
+ def _index_to_flat(self, i, j):
101
+ return i * self.M + j
102
+
103
+ def _flat_to_index(self, flat_idx):
104
+ return flat_idx // self.M, flat_idx % self.M
105
+
106
+ # ── density ───────────────────────────────────────────────────────────────
107
+
108
+ def set_density(self, density_func):
109
+ """
110
+ Set density field from a callable or array.
111
+
112
+ Parameters
113
+ ----------
114
+ density_func : callable or ndarray
115
+ If callable: ``density_func(x, y) → scalar`` evaluated at each
116
+ grid node (scalar x, y arguments, same signature as the 3-D
117
+ ``density_func(x, y, z)`` pattern in VDERMGrid).
118
+ If ndarray: shape must match (L, M).
119
+ """
120
+ if callable(density_func):
121
+ xx = self.min_bounds[0] + np.arange(self.L) * self.h
122
+ yy = self.min_bounds[1] + np.arange(self.M) * self.h
123
+ for i, x in enumerate(xx):
124
+ for j, y in enumerate(yy):
125
+ self.rho[i, j] = density_func(x, y)
126
+ else:
127
+ self.rho = np.asarray(density_func, dtype=float)
128
+
129
+ def update_density(self, dt):
130
+ """Diffuse density field one step with the 2-D heat equation."""
131
+ rho = self.rho
132
+ # Pad with edge values → Neumann (zero-flux) boundary conditions
133
+ rho_pad = np.pad(rho, pad_width=1, mode='edge')
134
+ laplacian = (
135
+ rho_pad[2:, 1:-1] + rho_pad[:-2, 1:-1] +
136
+ rho_pad[1:-1, 2:] + rho_pad[1:-1, :-2] -
137
+ 4.0 * rho
138
+ )
139
+ rho_new = rho + (dt / self.h ** 2) * laplacian
140
+ self.epsilon = np.linalg.norm(rho_new - rho) / np.mean(rho)
141
+ self.rho = rho_new
142
+
143
+ # ── velocities & positions ────────────────────────────────────────────────
144
+
145
+ def update_velocities(self):
146
+ """Compute nodal velocities from the density gradient."""
147
+ rho = self.rho
148
+ rho_pad = np.pad(rho, pad_width=1, mode='edge')
149
+ # Centred differences → ∇ρ
150
+ grad_x = rho_pad[2:, 1:-1] - rho_pad[:-2, 1:-1] # shape (L, M)
151
+ grad_y = rho_pad[1:-1, 2:] - rho_pad[1:-1, :-2] # shape (L, M)
152
+ # v = -∇ρ / (2h · ρ)
153
+ vx = -grad_x / (2.0 * self.h * rho)
154
+ vy = -grad_y / (2.0 * self.h * rho)
155
+ self.velocities[:, 0] = vx.ravel()
156
+ self.velocities[:, 1] = vy.ravel()
157
+
158
+ def update_positions(self, dt):
159
+ """Advect grid nodes: positions += dt * velocities."""
160
+ self.positions += dt * self.velocities
161
+
162
+ def get_displacement_field(self):
163
+ """Return per-node displacement vectors (shape: L*M × 2)."""
164
+ return self.positions - self.initial_positions
165
+
166
+ def compute_timestep(self):
167
+ """
168
+ CFL- and diffusion-stable timestep.
169
+
170
+ 2-D diffusion stability limit: dt ≤ h² / 4 (vs h² / 6 in 3-D).
171
+ """
172
+ max_speed = np.max(np.abs(self.velocities).sum(axis=1))
173
+ dt_advection = (2.0 * self.h / (3.0 * max_speed)
174
+ if max_speed > 1e-10 else np.inf)
175
+ dt_diffusion = self.h ** 2 / 4.0
176
+ dt = min(dt_advection, dt_diffusion) * 0.9
177
+ return min(dt, 0.01)
178
+
179
+
180
+ # ─── Grid utilities ──────────────────────────────────────────────────────────
181
+
182
+ def compute_grid_dimensions_2d(box_dims, max_points=4096):
183
+ """
184
+ Compute 2-D grid dimensions (L, M) and spacing h for a given box size.
185
+
186
+ Parameters
187
+ ----------
188
+ box_dims : array-like, shape (2,)
189
+ Desired box dimensions [x_size, y_size].
190
+ max_points : int, default=4096
191
+ Target total grid points.
192
+
193
+ Returns
194
+ -------
195
+ shape : tuple (L, M)
196
+ h : float
197
+ """
198
+ box_dims = np.asarray(box_dims, dtype=float)
199
+ aspect = box_dims / box_dims.min()
200
+ area_factor = np.prod(aspect)
201
+ base = (max_points / area_factor) ** 0.5
202
+ L, M = np.maximum(np.round(aspect * base).astype(int), 3)
203
+ h = box_dims[0] / (L - 1)
204
+ M = max(3, int(np.round(box_dims[1] / h)) + 1)
205
+ return (L, M), h
206
+
207
+
208
+ def make_initial_grid_2d(pts_2d, max_points=4096, padding=(2, 2)):
209
+ """
210
+ Generate grid parameters automatically sized to fit a 2-D point set.
211
+
212
+ Mirrors make_initial_grid() from core.py for 2-D inputs.
213
+
214
+ Parameters
215
+ ----------
216
+ pts_2d : ndarray, shape (n_points, 2)
217
+ Input point cloud [x, y].
218
+ max_points : int, default=4096
219
+ Target grid points (≈ 64² by default).
220
+ padding : tuple (x_ratio, y_ratio), default=(2, 2)
221
+ Grid extent as a multiple of the object bounding box.
222
+
223
+ Returns
224
+ -------
225
+ grid_params : dict
226
+ Keys: 'shape', 'h', 'min_bounds', 'max_bounds',
227
+ 'object_bounds', 'padding', 'actual_points'.
228
+ """
229
+ padding = np.asarray(padding, dtype=float)
230
+ obj_min = pts_2d.min(axis=0)
231
+ obj_max = pts_2d.max(axis=0)
232
+ obj_size = obj_max - obj_min
233
+ obj_center = (obj_min + obj_max) / 2.0
234
+
235
+ box_dims = padding * obj_size
236
+ shape, h = compute_grid_dimensions_2d(box_dims, max_points)
237
+ L, M = shape
238
+
239
+ grid_half = h * np.array([L - 1, M - 1]) / 2.0
240
+ grid_min = obj_center - grid_half
241
+ grid_max = obj_center + grid_half
242
+
243
+ return {
244
+ 'shape': (L, M),
245
+ 'h': h,
246
+ 'min_bounds': grid_min,
247
+ 'max_bounds': grid_max,
248
+ 'object_bounds': {
249
+ 'min': obj_min,
250
+ 'max': obj_max,
251
+ 'size': obj_size,
252
+ 'center': obj_center,
253
+ },
254
+ 'padding': tuple(padding),
255
+ 'actual_points': L * M,
256
+ }
257
+
258
+
259
+ def print_grid_info_2d(grid_params):
260
+ """Print a summary of a 2-D grid parameter dict."""
261
+ L, M = grid_params['shape']
262
+ h = grid_params['h']
263
+ ob = grid_params['object_bounds']
264
+ gsize = grid_params['max_bounds'] - grid_params['min_bounds']
265
+ osize = ob['size']
266
+
267
+ print("=" * 60)
268
+ print("2D GRID INFORMATION")
269
+ print("=" * 60)
270
+ print(f"\nGrid dimensions : {L} × {M} = {grid_params['actual_points']:,} points")
271
+ print(f"Grid spacing (h): {h:.6f}")
272
+ print(f"\nGrid size : [{gsize[0]:.4f}, {gsize[1]:.4f}]")
273
+ print(f"Object size: [{osize[0]:.4f}, {osize[1]:.4f}]")
274
+ print(f"Ratio : [{gsize[0]/osize[0]:.2f}x, {gsize[1]/osize[1]:.2f}x]")
275
+ print(f"\nGrid bounds:")
276
+ print(f" x: [{grid_params['min_bounds'][0]:.4f}, {grid_params['max_bounds'][0]:.4f}]")
277
+ print(f" y: [{grid_params['min_bounds'][1]:.4f}, {grid_params['max_bounds'][1]:.4f}]")
278
+ print(f"\nObject bounds:")
279
+ print(f" x: [{ob['min'][0]:.4f}, {ob['max'][0]:.4f}]")
280
+ print(f" y: [{ob['min'][1]:.4f}, {ob['max'][1]:.4f}]")
281
+
282
+ margins_min = ob['min'] - grid_params['min_bounds']
283
+ margins_max = grid_params['max_bounds'] - ob['max']
284
+ print(f"\nObject margins:")
285
+ print(f" x: min={margins_min[0]:.4f}, max={margins_max[0]:.4f}")
286
+ print(f" y: min={margins_min[1]:.4f}, max={margins_max[1]:.4f}")
287
+ print("=" * 60)
288
+
289
+
290
+ # ─── Interpolation ────────────────────────────────────────────────────────────
291
+
292
+ def interpolate_densities_2d(map_points, grid):
293
+ """
294
+ Interpolate density values from a VDERMGrid2D to a 2-D point set.
295
+
296
+ Parameters
297
+ ----------
298
+ map_points : ndarray, shape (n, 2)
299
+ grid : VDERMGrid2D
300
+
301
+ Returns
302
+ -------
303
+ densities : ndarray, shape (n,)
304
+ """
305
+ x = grid.min_bounds[0] + np.arange(grid.L) * grid.h
306
+ y = grid.min_bounds[1] + np.arange(grid.M) * grid.h
307
+ interp = RegularGridInterpolator(
308
+ (x, y), grid.rho, bounds_error=False, fill_value=0.0
309
+ )
310
+ return interp(map_points)
311
+
312
+
313
+ def interpolate_velocities_2d(map_points, grid_params, velocity_field):
314
+ """
315
+ Interpolate the 2-D velocity field to arbitrary map points.
316
+
317
+ Parameters
318
+ ----------
319
+ map_points : ndarray, shape (n, 2)
320
+ grid_params : dict
321
+ From make_initial_grid_2d; needs 'shape', 'h', 'min_bounds'.
322
+ velocity_field : ndarray, shape (L*M, 2)
323
+
324
+ Returns
325
+ -------
326
+ velocities : ndarray, shape (n, 2)
327
+ """
328
+ L, M = grid_params['shape']
329
+ h = grid_params['h']
330
+ mb = grid_params['min_bounds']
331
+ x = mb[0] + np.arange(L) * h
332
+ y = mb[1] + np.arange(M) * h
333
+ vg = velocity_field.reshape(L, M, 2)
334
+ iu = RegularGridInterpolator((x, y), vg[:, :, 0], bounds_error=False, fill_value=0.0)
335
+ iv = RegularGridInterpolator((x, y), vg[:, :, 1], bounds_error=False, fill_value=0.0)
336
+ return np.column_stack([iu(map_points), iv(map_points)])
337
+
338
+
339
+ def interpolate_to_map_2d(map_points, grid_params, displacement_field):
340
+ """
341
+ Apply grid displacements to 2-D map points and return deformed positions.
342
+
343
+ Analogous to interpolate_to_surface() in core.py.
344
+
345
+ Parameters
346
+ ----------
347
+ map_points : ndarray, shape (n, 2)
348
+ Original point positions.
349
+ grid_params : dict
350
+ displacement_field : ndarray, shape (L*M, 2)
351
+ From VDERMGrid2D.get_displacement_field().
352
+
353
+ Returns
354
+ -------
355
+ deformed_points : ndarray, shape (n, 2)
356
+ """
357
+ L, M = grid_params['shape']
358
+ h = grid_params['h']
359
+ mb = grid_params['min_bounds']
360
+ x = mb[0] + np.arange(L) * h
361
+ y = mb[1] + np.arange(M) * h
362
+ dg = displacement_field.reshape(L, M, 2)
363
+ iu = RegularGridInterpolator((x, y), dg[:, :, 0], bounds_error=False, fill_value=0.0)
364
+ iv = RegularGridInterpolator((x, y), dg[:, :, 1], bounds_error=False, fill_value=0.0)
365
+ return map_points + np.column_stack([iu(map_points), iv(map_points)])
366
+
367
+
368
+ # ─── 2-D I/O ─────────────────────────────────────────────────────────────────
369
+
370
+ def write_csv_2d(filepath, positions, densities=None):
371
+ """
372
+ Write 2-D point positions (and optionally densities) to a
373
+ space-delimited file.
374
+
375
+ Column formats:
376
+ - 2 columns: ``x y``
377
+ - 3 columns: ``x y rho``
378
+
379
+ Parameters
380
+ ----------
381
+ filepath : str
382
+ positions : ndarray, shape (n, 2)
383
+ densities : ndarray, shape (n,), optional
384
+ """
385
+ if densities is None:
386
+ data = positions
387
+ else:
388
+ dens = densities.reshape(-1, 1) if densities.ndim == 1 else densities
389
+ data = np.hstack([positions, dens])
390
+ np.savetxt(filepath, data, fmt='%.6e', delimiter=' ')
391
+
392
+
393
+ def read_csv_2d(filepath):
394
+ """
395
+ Read 2-D point data from a space-delimited file.
396
+
397
+ Accepts 2-column (x y) or 3-column (x y rho) files.
398
+
399
+ Returns
400
+ -------
401
+ positions : ndarray, shape (n, 2)
402
+ densities : ndarray, shape (n,) or None
403
+ """
404
+ data = np.loadtxt(filepath)
405
+ if data.ndim == 1:
406
+ data = data.reshape(1, -1)
407
+ n_cols = data.shape[1]
408
+ if n_cols == 2:
409
+ return data[:, :2], None
410
+ elif n_cols == 3:
411
+ return data[:, :2], data[:, 2]
412
+ else:
413
+ raise ValueError(
414
+ f"Unrecognised 2-D CSV format: {n_cols} columns. "
415
+ "Expected 2 (x y) or 3 (x y rho)."
416
+ )
417
+
418
+
419
+ # ─── Geographic I/O ──────────────────────────────────────────────────────────
420
+
421
+ def _extract_from_geometry(geom):
422
+ """Recursively extract (x, y) coordinate pairs from a Shapely geometry."""
423
+ gtype = geom.geom_type
424
+ coords = []
425
+ if gtype == 'Point':
426
+ coords.append([geom.x, geom.y])
427
+ elif gtype == 'MultiPoint':
428
+ for pt in geom.geoms:
429
+ coords.append([pt.x, pt.y])
430
+ elif gtype == 'LineString':
431
+ coords.extend([[c[0], c[1]] for c in geom.coords])
432
+ elif gtype == 'MultiLineString':
433
+ for ln in geom.geoms:
434
+ coords.extend([[c[0], c[1]] for c in ln.coords])
435
+ elif gtype == 'Polygon':
436
+ coords.extend([[c[0], c[1]] for c in geom.exterior.coords])
437
+ elif gtype == 'MultiPolygon':
438
+ for poly in geom.geoms:
439
+ coords.extend([[c[0], c[1]] for c in poly.exterior.coords])
440
+ elif gtype == 'GeometryCollection':
441
+ for g in geom.geoms:
442
+ coords.extend(_extract_from_geometry(g))
443
+ return coords
444
+
445
+
446
+ def _points_from_geodataframe(gdf):
447
+ """Return (ndarray (n,2), crs_str) from a GeoDataFrame."""
448
+ crs = str(gdf.crs) if gdf.crs is not None else None
449
+ all_coords = []
450
+ for geom in gdf.geometry:
451
+ if geom is not None:
452
+ all_coords.extend(_extract_from_geometry(geom))
453
+ if not all_coords:
454
+ raise ValueError("No coordinates found in file.")
455
+ return np.array(all_coords), crs
456
+
457
+
458
+ def read_geojson(filepath):
459
+ """
460
+ Extract a 2-D point array from a GeoJSON file.
461
+
462
+ For Polygon / MultiPolygon features the exterior ring vertices are
463
+ extracted. Point and LineString features are also supported.
464
+
465
+ Requires geopandas (``pip install diffusion-cartogram[2D]``).
466
+
467
+ Parameters
468
+ ----------
469
+ filepath : str
470
+ Path to a .geojson or .json file.
471
+
472
+ Returns
473
+ -------
474
+ points : ndarray, shape (n, 2)
475
+ [x, y] coordinates in the file's native CRS.
476
+ crs : str or None
477
+ Coordinate reference system string.
478
+
479
+ Examples
480
+ --------
481
+ >>> pts, crs = vd.read_geojson('countries.geojson')
482
+ >>> grid_params = vd.make_initial_grid_2d(pts)
483
+ """
484
+ _require_geopandas('read_geojson')
485
+ gdf = gpd.read_file(filepath)
486
+ return _points_from_geodataframe(gdf)
487
+
488
+
489
+ def read_shapefile(filepath):
490
+ """
491
+ Extract a 2-D point array from a Shapefile (.shp).
492
+
493
+ Requires geopandas (``pip install diffusion-cartogram[2D]``).
494
+
495
+ If the .shx index file is missing, it is reconstructed automatically
496
+ via GDAL's ``SHAPE_RESTORE_SHX`` option (geometry-only recovery).
497
+ Note that without the companion .dbf file, attribute columns will
498
+ not be available, but geometry extraction still works.
499
+
500
+ Parameters
501
+ ----------
502
+ filepath : str
503
+ Path to .shp file.
504
+
505
+ Returns
506
+ -------
507
+ points : ndarray, shape (n, 2)
508
+ crs : str or None
509
+
510
+ Examples
511
+ --------
512
+ >>> pts, crs = vd.read_shapefile('states.shp')
513
+ >>> grid_params = vd.make_initial_grid_2d(pts)
514
+ """
515
+ _require_geopandas('read_shapefile')
516
+ import os
517
+ try:
518
+ gdf = gpd.read_file(filepath)
519
+ except Exception:
520
+ # Missing .shx — try with GDAL's auto-recovery option
521
+ old = os.environ.get('SHAPE_RESTORE_SHX')
522
+ os.environ['SHAPE_RESTORE_SHX'] = 'YES'
523
+ try:
524
+ gdf = gpd.read_file(filepath)
525
+ finally:
526
+ if old is None:
527
+ os.environ.pop('SHAPE_RESTORE_SHX', None)
528
+ else:
529
+ os.environ['SHAPE_RESTORE_SHX'] = old
530
+ return _points_from_geodataframe(gdf)
531
+
532
+
533
+ def read_geotiff(filepath, band=1):
534
+ """
535
+ Read a GeoTIFF raster band and return data plus coordinate arrays.
536
+
537
+ Requires rasterio (``pip install diffusion-cartogram[2D]``).
538
+
539
+ Parameters
540
+ ----------
541
+ filepath : str
542
+ band : int, default=1
543
+
544
+ Returns
545
+ -------
546
+ data : ndarray, shape (H, W)
547
+ Raster values; NaN where nodata.
548
+ x_coords : ndarray, shape (W,)
549
+ Pixel-centre x coordinates (ascending).
550
+ y_coords : ndarray, shape (H,)
551
+ Pixel-centre y coordinates (may be descending for north-up).
552
+ crs : str or None
553
+
554
+ Examples
555
+ --------
556
+ >>> data, xs, ys, crs = vd.read_geotiff('population.tif')
557
+ """
558
+ _require_rasterio('read_geotiff')
559
+ with rasterio.open(filepath) as src:
560
+ data = src.read(band).astype(float)
561
+ nodata = src.nodata
562
+ if nodata is not None:
563
+ data[data == nodata] = np.nan
564
+ H, W = data.shape
565
+ x_coords = np.array([src.xy(0, c)[0] for c in range(W)])
566
+ y_coords = np.array([src.xy(r, 0)[1] for r in range(H)])
567
+ crs = str(src.crs) if src.crs is not None else None
568
+ return data, x_coords, y_coords, crs
569
+
570
+
571
+ def density_from_geotiff(grid_2d, filepath, band=1, nodata_fill=1.0,
572
+ normalize=True):
573
+ """
574
+ Sample a GeoTIFF raster onto a VDERMGrid2D density field.
575
+
576
+ The raster is bilinearly interpolated to every grid node. NaN /
577
+ nodata pixels are replaced by ``nodata_fill``. The grid and the
578
+ raster must share the same coordinate reference system.
579
+
580
+ Parameters
581
+ ----------
582
+ grid_2d : VDERMGrid2D
583
+ filepath : str
584
+ band : int, default=1
585
+ nodata_fill : float, default=1.0
586
+ Value substituted for missing data.
587
+ normalize : bool, default=True
588
+ If True, rescale so that mean(ρ) = 1.
589
+
590
+ Returns
591
+ -------
592
+ grid_2d : VDERMGrid2D
593
+ The same object with its ``rho`` field updated.
594
+
595
+ Examples
596
+ --------
597
+ >>> grid = vd.VDERMGrid2D(shape, h, min_bounds)
598
+ >>> vd.density_from_geotiff(grid, 'population.tif')
599
+ >>> result = vd.run_VDERM(grid)
600
+ """
601
+ data, x_coords, y_coords, _ = read_geotiff(filepath, band=band)
602
+ data = np.where(np.isnan(data), nodata_fill, data)
603
+
604
+ # RegularGridInterpolator requires strictly ascending axes
605
+ if y_coords[0] > y_coords[-1]:
606
+ y_sorted = y_coords[::-1]
607
+ data_sorted = data[::-1, :]
608
+ else:
609
+ y_sorted = y_coords
610
+ data_sorted = data
611
+
612
+ # Transpose: data is (H, W) = (y, x); interpolator wants (x, y)
613
+ interp = RegularGridInterpolator(
614
+ (x_coords, y_sorted),
615
+ data_sorted.T,
616
+ bounds_error=False,
617
+ fill_value=nodata_fill,
618
+ )
619
+
620
+ density = interp(grid_2d.positions).reshape(grid_2d.L, grid_2d.M)
621
+
622
+ if normalize:
623
+ mean_d = np.mean(density)
624
+ if mean_d > 0:
625
+ density = density / mean_d
626
+
627
+ grid_2d.rho = density
628
+ return grid_2d
629
+
630
+
631
+ # ─── Tracking run ─────────────────────────────────────────────────────────────
632
+
633
+ def run_VDERM_2d_with_tracking(
634
+ grid, map_points,
635
+ n_max=100, max_eps=0.01, dt=None,
636
+ export_grid=False, export_grid_frequency=10,
637
+ export_map=False, export_map_frequency=10,
638
+ base_folder='vderm_2d_exports',
639
+ grid_folder='vderm_grid',
640
+ map_folder='vderm_map'):
641
+ """
642
+ Run 2-D VDERM deformation with optional intermediate state exports.
643
+
644
+ Extends run_VDERM() (which works unchanged with VDERMGrid2D) by
645
+ supporting export of:
646
+
647
+ - **Grid states**: node positions, velocities, and densities
648
+ (5-column CSV: ``x y v_x v_y rho``).
649
+ - **Map point states**: deformed map points with interpolated
650
+ densities (3-column CSV: ``x y rho``).
651
+
652
+ Parameters
653
+ ----------
654
+ grid : VDERMGrid2D
655
+ Grid with density field already set via ``grid.set_density()``.
656
+ map_points : ndarray, shape (n, 2)
657
+ Point set whose deformation will be tracked (e.g. polygon
658
+ boundary extracted from a GeoJSON file).
659
+ n_max : int, default=100
660
+ Maximum iterations.
661
+ max_eps : float, default=0.01
662
+ Convergence threshold (relative L2 norm of density change).
663
+ dt : float, optional
664
+ Manual timestep. Auto-computed from CFL / diffusion limits if None.
665
+ export_grid : bool, default=False
666
+ Export grid state CSVs.
667
+ export_grid_frequency : int, default=10
668
+ Export every N iterations.
669
+ export_map : bool, default=False
670
+ Export deformed map point CSVs.
671
+ export_map_frequency : int, default=10
672
+ Export every N iterations.
673
+ base_folder : str, default='vderm_2d_exports'
674
+ Root export directory.
675
+ grid_folder : str, default='vderm_grid'
676
+ Subfolder for grid exports.
677
+ map_folder : str, default='vderm_map'
678
+ Subfolder for map exports.
679
+
680
+ Returns
681
+ -------
682
+ grid : VDERMGrid2D
683
+ Final deformed grid.
684
+
685
+ Examples
686
+ --------
687
+ >>> pts, _ = vd.read_geojson('countries.geojson')
688
+ >>> gp = vd.make_initial_grid_2d(pts, max_points=16384)
689
+ >>> grid = vd.VDERMGrid2D(gp['shape'], gp['h'], gp['min_bounds'])
690
+ >>> vd.density_from_geotiff(grid, 'population.tif')
691
+ >>> final = vd.run_VDERM_2d_with_tracking(
692
+ ... grid, pts,
693
+ ... export_grid=True, export_map=True
694
+ ... )
695
+ >>> deformed = vd.interpolate_to_map_2d(
696
+ ... pts, gp, final.get_displacement_field()
697
+ ... )
698
+ """
699
+ any_exports = export_grid or export_map
700
+
701
+ if any_exports:
702
+ os.makedirs(base_folder, exist_ok=True)
703
+ if export_grid:
704
+ os.makedirs(os.path.join(base_folder, grid_folder), exist_ok=True)
705
+ if export_map:
706
+ os.makedirs(os.path.join(base_folder, map_folder), exist_ok=True)
707
+
708
+ grid.update_velocities()
709
+
710
+ if dt is None:
711
+ dt = grid.compute_timestep()
712
+ if dt < 0.005:
713
+ print(f" Warning: Very small timestep ({dt:.6f})")
714
+ print(f" This may indicate strong density gradients.")
715
+
716
+ grid.epsilon = None
717
+
718
+ # ── initial state export (iteration 0) ───────────────────────────────
719
+ if any_exports:
720
+ params = {'shape': (grid.L, grid.M), 'h': grid.h,
721
+ 'min_bounds': grid.min_bounds}
722
+
723
+ if export_grid:
724
+ densities = grid.rho.ravel()
725
+ vels = grid.velocities
726
+ data = np.hstack([grid.positions, vels,
727
+ densities.reshape(-1, 1)])
728
+ np.savetxt(
729
+ os.path.join(base_folder, grid_folder,
730
+ 'grid_iteration_0000.csv'),
731
+ data, fmt='%.6e', delimiter=' '
732
+ )
733
+
734
+ if export_map:
735
+ map_dens = interpolate_densities_2d(map_points, grid)
736
+ write_csv_2d(
737
+ os.path.join(base_folder, map_folder,
738
+ 'map_iteration_0000.csv'),
739
+ map_points, map_dens
740
+ )
741
+
742
+ pbar = tqdm(range(n_max), desc='Deforming (2D)')
743
+
744
+ for iteration in pbar:
745
+ grid.update_density(dt)
746
+ if iteration > 0:
747
+ grid.update_velocities()
748
+ grid.update_positions(dt)
749
+
750
+ # ── instability check ─────────────────────────────────────────────
751
+ if grid.epsilon is not None:
752
+ if grid.epsilon > 1e6 or grid.epsilon < -1e-6 or np.isnan(grid.epsilon):
753
+ pbar.close()
754
+ print(f"\nINSTABILITY at iteration {iteration}!")
755
+ print(f" Epsilon: {grid.epsilon:.3e}")
756
+ print(f" Current dt: {dt:.6f}")
757
+ print(f"\n Solution: set a smaller timestep manually:")
758
+ print(f" run_VDERM_2d_with_tracking(grid, ..., dt={dt/10:.6f})")
759
+ raise RuntimeError(
760
+ "Numerical instability detected. "
761
+ "Please manually set a smaller timestep and rerun"
762
+ )
763
+
764
+ if grid.epsilon is not None:
765
+ pbar.set_postfix({'eps': f'{grid.epsilon:.3e}',
766
+ 'target': f'{max_eps:.3e}'})
767
+
768
+ # ── periodic exports ──────────────────────────────────────────────
769
+ if any_exports:
770
+ params = {'shape': (grid.L, grid.M), 'h': grid.h,
771
+ 'min_bounds': grid.min_bounds}
772
+
773
+ if export_grid and (iteration + 1) % export_grid_frequency == 0:
774
+ densities = grid.rho.ravel()
775
+ data = np.hstack([grid.positions, grid.velocities,
776
+ densities.reshape(-1, 1)])
777
+ np.savetxt(
778
+ os.path.join(base_folder, grid_folder,
779
+ f'grid_iteration_{iteration+1:04d}.csv'),
780
+ data, fmt='%.6e', delimiter=' '
781
+ )
782
+
783
+ if export_map and (iteration + 1) % export_map_frequency == 0:
784
+ disp = grid.get_displacement_field()
785
+ cur_map = interpolate_to_map_2d(map_points, params, disp)
786
+ cur_dens = interpolate_densities_2d(map_points, grid)
787
+ write_csv_2d(
788
+ os.path.join(base_folder, map_folder,
789
+ f'map_iteration_{iteration+1:04d}.csv'),
790
+ cur_map, cur_dens
791
+ )
792
+
793
+ # ── convergence ───────────────────────────────────────────────────
794
+ if grid.epsilon is not None and grid.epsilon <= max_eps:
795
+ pbar.set_description('Converged')
796
+ pbar.close()
797
+ print(f'\nConverged at iteration {iteration + 1}')
798
+
799
+ if any_exports:
800
+ params = {'shape': (grid.L, grid.M), 'h': grid.h,
801
+ 'min_bounds': grid.min_bounds}
802
+ disp = grid.get_displacement_field()
803
+
804
+ if export_grid:
805
+ densities = grid.rho.ravel()
806
+ data = np.hstack([grid.positions, grid.velocities,
807
+ densities.reshape(-1, 1)])
808
+ np.savetxt(
809
+ os.path.join(base_folder, grid_folder,
810
+ f'grid_final_iteration_{iteration+1:04d}.csv'),
811
+ data, fmt='%.6e', delimiter=' '
812
+ )
813
+
814
+ if export_map:
815
+ final_map = interpolate_to_map_2d(map_points, params, disp)
816
+ final_dens = interpolate_densities_2d(map_points, grid)
817
+ write_csv_2d(
818
+ os.path.join(base_folder, map_folder,
819
+ f'map_final_iteration_{iteration+1:04d}.csv'),
820
+ final_map, final_dens
821
+ )
822
+ break
823
+
824
+ if any_exports:
825
+ print(f"\nExports saved to: {base_folder}/")
826
+ if export_grid:
827
+ print(f" Grid states (x y v_x v_y rho): {grid_folder}/")
828
+ if export_map:
829
+ print(f" Map point states (x y rho): {map_folder}/")
830
+
831
+ return grid