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.
- diffusion_cartogram/__init__.py +202 -0
- diffusion_cartogram/core.py +1329 -0
- diffusion_cartogram/core_2d.py +831 -0
- diffusion_cartogram/visualization.py +1490 -0
- diffusion_cartogram/visualization_2d.py +534 -0
- diffusion_cartogram-0.2.0.dist-info/METADATA +287 -0
- diffusion_cartogram-0.2.0.dist-info/RECORD +10 -0
- diffusion_cartogram-0.2.0.dist-info/WHEEL +5 -0
- diffusion_cartogram-0.2.0.dist-info/licenses/LICENSE +21 -0
- diffusion_cartogram-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|