incompy2d 0.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.
incompy2d/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Incompy2D: A 2D incompressible Navier-Stokes CFD solver."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .mesh_util import (
6
+ StructuredMesh2D,
7
+ uniform_msh,
8
+ load_npz,
9
+ )
10
+ from .geom_util import (
11
+ solid_poly,
12
+ geom_geojson,
13
+ )
14
+ from .incomp_2d import (
15
+ SolverConfig,
16
+ default_pressure_bc,
17
+ default_velocity_bc,
18
+ wind_pressure_bc,
19
+ wind_velocity_bc,
20
+ solve_incomp,
21
+ )
22
+
23
+ __all__ = [
24
+ "SolverConfig",
25
+ "StructuredMesh2D",
26
+ "uniform_msh",
27
+ "load_npz",
28
+ "solid_poly",
29
+ "geom_geojson",
30
+ "default_velocity_bc",
31
+ "default_pressure_bc",
32
+ "wind_velocity_bc",
33
+ "wind_pressure_bc",
34
+ "solve_incomp",
35
+ ]
@@ -0,0 +1,91 @@
1
+ import subprocess
2
+ import geopandas as gpd
3
+ from pathlib import Path
4
+ import argparse
5
+
6
+ def fetch_overture(bbox, output_dir=None, output_filename="overture_bld.geojson"):
7
+ """
8
+ Fetch building footprints for specified bounding box
9
+ using the overturemaps CLI (for streaming, low-RAM downloads)
10
+ and saves to geojson
11
+
12
+ Usage:
13
+ python fetch_overture.py
14
+ --bbox <xmin> <ymin> <xmax> <ymax>
15
+ --output <file-name>.geojson
16
+ """
17
+
18
+ print(f"Fetching buildings for bbox: {bbox}")
19
+
20
+ ###Define output path
21
+ if output_dir is None:
22
+ output_dir = Path.cwd() / "geom"
23
+ else:
24
+ output_dir = Path(output_dir)
25
+ output_dir.mkdir(parents=True, exist_ok=True)
26
+ output_file = output_dir / output_filename
27
+
28
+ ###Construct bbox string (xmin,ymin,xmax,ymax)
29
+ bbox_str = f"{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}"
30
+
31
+ cmd = [
32
+ "overturemaps", "download",
33
+ "--bbox", bbox_str,
34
+ "-f", "geojson",
35
+ "--type", "building",
36
+ "-o", str(output_file)
37
+ ]
38
+
39
+ print(f"Running CLI command: {' '.join(cmd)}")
40
+
41
+ try:
42
+ subprocess.run(cmd, check=True)
43
+ except subprocess.CalledProcessError as e:
44
+ print(f"Error downloading Overture data: {e}")
45
+ return None
46
+
47
+ print(f"Successfully saved building footprints to {output_file}")
48
+
49
+ print("Loading saved GeoJSON into GeoDataFrame...")
50
+
51
+ ###rewrite as UTF-8 for GeoPandas/pyogrio to parse
52
+ try:
53
+ with open(output_file, 'r', encoding='mbcs') as f:
54
+ file_data = f.read()
55
+ with open(output_file, 'w', encoding='utf-8') as f:
56
+ f.write(file_data)
57
+ except Exception as e:
58
+ print(f"Warning: Could not re-encode GeoJSON: {e}")
59
+
60
+ bld_gdf = gpd.read_file(output_file)
61
+ print(f"Loaded {len(bld_gdf)} buildings.")
62
+
63
+ return bld_gdf
64
+
65
+ if __name__ == "__main__":
66
+ print("\n=========Fetch building footprints from Overture Maps=========")
67
+ user_input = input("Use this link to find your bounding box (Copy CSV format): https://boundingbox.klokantech.com \n\
68
+ Paste CSV bounding box: ").strip()
69
+
70
+ default_bbox = (6.076267, 50.776895, 6.080700, 50.778798)
71
+
72
+ if user_input:
73
+ try:
74
+ cleaned = user_input.replace(',', ' ').replace('[', ' ').replace(']', ' ')
75
+ coords = [float(x) for x in cleaned.split()]
76
+ if len(coords) == 4:
77
+ bbox = tuple(coords)
78
+ elif len(coords) >= 8 and len(coords) % 2 == 0:
79
+ x_coords = coords[0::2]
80
+ y_coords = coords[1::2]
81
+ bbox = (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
82
+ else:
83
+ raise ValueError("Could not parse coordinates. Expected 4 values or a polygon list.")
84
+ except Exception as e:
85
+ print(f"Invalid input ({e}). Using default bbox.")
86
+ bbox = default_bbox
87
+ else:
88
+ print("No input provided. Using default bbox.")
89
+ bbox = default_bbox
90
+
91
+ fetch_overture(bbox=bbox)
incompy2d/geom_util.py ADDED
@@ -0,0 +1,141 @@
1
+ import numpy as np
2
+
3
+ def solid_poly(mesh, vertices):
4
+ """Mark cells inside polygon as solid using ray-casting.
5
+
6
+ Args:
7
+ mesh: Mesh object
8
+ vertices: List of (x, y) tuples representing the polygon vertices
9
+
10
+ Returns:
11
+ None
12
+ """
13
+
14
+ ###Convert vertices to numpy arrays
15
+ xv = np.asarray([pt[0] for pt in vertices], dtype=float)
16
+ yv = np.asarray([pt[1] for pt in vertices], dtype=float)
17
+
18
+ ###Get mesh coordinates
19
+ xq = mesh.X
20
+ yq = mesh.Y
21
+
22
+ ###Initialize inside mask for the polygon ray-casting
23
+ inside = np.zeros_like(xq, dtype=bool)
24
+
25
+ ###Ray-casting algorithm to find points inside the polygon
26
+ j = len(xv) - 1
27
+ for i in range(len(xv)):
28
+ xi, yi = xv[i], yv[i]
29
+ xj, yj = xv[j], yv[j]
30
+ cond = ((yi > yq) != (yj > yq)) & (
31
+ xq < (xj - xi) * (yq - yi) / (yj - yi + 1.0e-14) + xi
32
+ )
33
+ inside = np.logical_xor(inside, cond)
34
+ j = i
35
+
36
+ mesh.fluid_mask[inside] = False
37
+
38
+ def geom_png(mesh, output_path):
39
+ """
40
+ Export geom (fluid/solid mask) to PNG
41
+
42
+ Args:
43
+ mesh (StructuredMesh2D): Mesh object containing fluid mask.
44
+ output_path (str): .png file path to save
45
+ """
46
+ import matplotlib.pyplot as plt
47
+ import os
48
+
49
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
50
+
51
+ plt.figure(figsize=(10, 8))
52
+
53
+ fig_width = 10
54
+ fig_height = fig_width * (mesh.y[-1] / mesh.x[-1])
55
+ plt.figure(figsize=(fig_width, fig_height))
56
+
57
+ ###fluid mask: 1 fluid, 0 solid
58
+ plt.pcolormesh(mesh.X, mesh.Y, mesh.fluid_mask.astype(float), cmap='gray', shading='nearest')
59
+
60
+ plt.title("Computational Domain")
61
+ plt.xlabel("X")
62
+ plt.ylabel("Y")
63
+ plt.axis('equal')
64
+ plt.tight_layout()
65
+ plt.savefig(output_path, dpi=300)
66
+ plt.close()
67
+
68
+
69
+ def geom_geojson(geojson_path, dx=1, padding=30):
70
+ """
71
+ Read GeoJSON
72
+ -> projects to metric CRS
73
+ -> adds padding
74
+ -> creates Cartesian mesh
75
+ -> rasterizes polygons into solid/fluid mask
76
+
77
+ Args:
78
+ geojson_path: Path to the GeoJSON file
79
+ dx: Grid spacing
80
+ padding: Padding around bbox
81
+
82
+ Returns:
83
+ mesh (StructuredMesh2D)
84
+ gdf (GeoDataFrame): Shifted GeoDataFrame (origin = 0,0)
85
+ """
86
+
87
+ import geopandas as gpd
88
+ import shapely.affinity
89
+ from .mesh_util import uniform_msh
90
+ from pathlib import Path
91
+
92
+ geojson_path = Path(geojson_path)
93
+ if not geojson_path.exists():
94
+ raise FileNotFoundError(f"Could not find GeoJSON file at {geojson_path}")
95
+
96
+ gdf = gpd.read_file(geojson_path)
97
+
98
+ ###Project to metric coord system (UTM zone 32N for Aachen)
99
+ print("Projecting footprints to EPSG:32632 metric coordinates")
100
+ gdf = gdf.to_crs(epsg=32632)
101
+
102
+ ###Define domain bounds and shift to origin
103
+ minx, miny, maxx, maxy = gdf.total_bounds
104
+
105
+ minx -= padding
106
+ miny -= padding
107
+ maxx += padding
108
+ maxy += padding
109
+
110
+ lx = maxx - minx
111
+ ly = maxy - miny
112
+ print(f"Domain size with padding: lx = {lx:.1f} m, ly = {ly:.1f} m")
113
+
114
+ ###Shift geometries for bottom-left of padded domain to be at (0, 0)
115
+ gdf['geometry'] = gdf['geometry'].apply(
116
+ lambda geom: shapely.affinity.translate(geom, xoff=-minx, yoff=-miny)
117
+ )
118
+
119
+ ###Create mesh
120
+ nx = int(lx / dx)
121
+ ny = int(ly / dx)
122
+ print(f"Creating uniform mesh of size {nx} x {ny} (dx = {lx/(nx-1):.2f}m)...")
123
+ mesh = uniform_msh(nx=nx, ny=ny, lx=lx, ly=ly)
124
+
125
+ ###Rasterize buildings onto the mesh
126
+ print("Rasterizing building footprints onto mesh...")
127
+ for geom in gdf.geometry:
128
+ if geom is None:
129
+ continue
130
+ if geom.geom_type == 'Polygon':
131
+ coords = list(geom.exterior.coords)
132
+ solid_poly(mesh, coords)
133
+ elif geom.geom_type == 'MultiPolygon':
134
+ for poly in geom.geoms:
135
+ coords = list(poly.exterior.coords)
136
+ solid_poly(mesh, coords)
137
+
138
+ num_solid = np.sum(~mesh.fluid_mask)
139
+ print(f"Rasterization complete. {num_solid} cells marked as solid obstacles.")
140
+
141
+ return mesh, gdf
incompy2d/incomp_2d.py ADDED
@@ -0,0 +1,360 @@
1
+ """Generic 2D incompressible Navier-Stokes solver (projection method)."""
2
+
3
+ from typing import Callable
4
+
5
+ import numpy as np
6
+ import numba
7
+
8
+ from .mesh_util import StructuredMesh2D
9
+
10
+
11
+ class SolverConfig:
12
+ """
13
+ Time-stepping and physical parameters for the NS solver
14
+
15
+ Attributes:
16
+ rho: Fluid density
17
+ nu: Fluid kinematic viscosity
18
+ dt: Time step size
19
+ nt: Number of time steps
20
+ nit: Number of pressure Poisson iterations
21
+ fx: Body force in the x-direction
22
+ fy: Body force in the y-direction
23
+ monitor: Number of steps between pressure updates
24
+
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ rho: float = 1.0,
30
+ nu: float = 0.1,
31
+ dt: float = 0.001,
32
+ nt: int = 600,
33
+ nit: int = 60,
34
+ fx: float = 0.0,
35
+ fy: float = 0.0,
36
+ monitor: int = 50,
37
+ ):
38
+ """Initialize solver constants and runtime options."""
39
+ self.rho = rho
40
+ self.nu = nu
41
+ self.dt = dt
42
+ self.nt = nt
43
+ self.nit = nit
44
+ self.fx = fx
45
+ self.fy = fy
46
+ self.monitor = monitor
47
+
48
+
49
+ def init_field(mesh: StructuredMesh2D):
50
+ """Allocate zero-initialized velocity and pressure fields."""
51
+ u = np.zeros((mesh.ny, mesh.nx))
52
+ v = np.zeros((mesh.ny, mesh.nx))
53
+ p = np.zeros((mesh.ny, mesh.nx))
54
+ return u, v, p
55
+
56
+
57
+ @numba.njit(parallel=True, fastmath=True)
58
+ def divergence(u: np.ndarray, v: np.ndarray, dx: float, dy: float):
59
+ """
60
+ Compute cell-centered velocity divergence with central differences.
61
+ """
62
+ ny, nx = u.shape
63
+ div = np.zeros_like(u)
64
+ for i in numba.prange(1, ny - 1): # type: ignore
65
+ for j in range(1, nx - 1):
66
+ div[i, j] = (u[i, j+1] - u[i, j-1]) / (2.0 * dx) + (v[i+1, j] - v[i-1, j]) / (2.0 * dy)
67
+ return div
68
+
69
+ @numba.njit(parallel=True, fastmath=True)
70
+ def _compute_intermediate_velocity(u, v, p, fluid_mask, active_inner, dx, dy, dt, rho, nu, fx, fy):
71
+ ny, nx = u.shape
72
+ u_star = u.copy()
73
+ v_star = v.copy()
74
+
75
+ for i in numba.prange(1, ny - 1): # type: ignore
76
+ for j in range(1, nx - 1):
77
+ if active_inner[i, j]:
78
+ u_c = u[i, j]
79
+ v_c = v[i, j]
80
+
81
+ u_pos = max(u_c, 0.0)
82
+ u_neg = min(u_c, 0.0)
83
+ v_pos = max(v_c, 0.0)
84
+ v_neg = min(v_c, 0.0)
85
+
86
+ u_adv_x = u_pos * (u_c - u[i, j-1]) / dx + u_neg * (u[i, j+1] - u_c) / dx
87
+ u_adv_y = v_pos * (u_c - u[i-1, j]) / dy + v_neg * (u[i+1, j] - u_c) / dy
88
+
89
+ v_adv_x = u_pos * (v_c - v[i, j-1]) / dx + u_neg * (v[i, j+1] - v_c) / dx
90
+ v_adv_y = v_pos * (v_c - v[i-1, j]) / dy + v_neg * (v[i+1, j] - v_c) / dy
91
+
92
+ u_diff = nu * ((u[i, j+1] - 2.0*u_c + u[i, j-1])/dx**2 + (u[i+1, j] - 2.0*u_c + u[i-1, j])/dy**2)
93
+ v_diff = nu * ((v[i, j+1] - 2.0*v_c + v[i, j-1])/dx**2 + (v[i+1, j] - 2.0*v_c + v[i-1, j])/dy**2)
94
+
95
+ p_grad_x = (p[i, j+1] - p[i, j-1]) / (2.0 * dx * rho)
96
+ p_grad_y = (p[i+1, j] - p[i-1, j]) / (2.0 * dy * rho)
97
+
98
+ u_star[i, j] = u_c + dt * (-u_adv_x - u_adv_y - p_grad_x + u_diff + fx)
99
+ v_star[i, j] = v_c + dt * (-v_adv_x - v_adv_y - p_grad_y + v_diff + fy)
100
+
101
+ for i in range(ny):
102
+ for j in range(nx):
103
+ if not fluid_mask[i, j]:
104
+ u_star[i, j] = 0.0
105
+ v_star[i, j] = 0.0
106
+
107
+ return u_star, v_star
108
+
109
+ @numba.njit(parallel=True, fastmath=True)
110
+ def pressure_poisson(
111
+ phi: np.ndarray,
112
+ b: np.ndarray,
113
+ fluid_mask: np.ndarray,
114
+ dx: float,
115
+ dy: float,
116
+ nit: int,
117
+ bc_type: int
118
+ ):
119
+ """
120
+ Solve pressure-correction Poisson equation by Jacobi iterations
121
+ bc_type=1 implies wind_pressure_bc
122
+ bc_type=0 implies default_pressure_bc
123
+ """
124
+ ny, nx = phi.shape
125
+ dx2 = dx**2
126
+ dy2 = dy**2
127
+ denom = 2.0 * (dx2 + dy2)
128
+ coeff_b = (dx2 * dy2) / denom
129
+
130
+ for _ in range(nit):
131
+ phin = phi.copy()
132
+
133
+ for i in numba.prange(1, ny - 1): # type: ignore
134
+ for j in range(1, nx - 1):
135
+ phi[i, j] = ((phin[i, j+1] + phin[i, j-1]) * dy2 + (phin[i+1, j] + phin[i-1, j]) * dx2) / denom - coeff_b * b[i, j]
136
+ if not fluid_mask[i, j]:
137
+ phi[i, j] = 0.0
138
+
139
+ if bc_type == 1:
140
+ for i in range(ny):
141
+ phi[i, 0] = phi[i, 1]
142
+ phi[i, nx-1] = 0.0
143
+ for j in range(nx):
144
+ phi[0, j] = phi[1, j]
145
+ phi[ny-1, j] = phi[ny-2, j]
146
+ else:
147
+ for i in range(ny):
148
+ phi[i, nx-1] = phi[i, nx-2]
149
+ phi[i, 0] = phi[i, 1]
150
+ for j in range(nx):
151
+ phi[0, j] = phi[1, j]
152
+ phi[ny-1, j] = phi[ny-2, j]
153
+ phi[ny-1, nx-1] = 0.0
154
+
155
+ return phi
156
+
157
+ @numba.njit(parallel=True, fastmath=True)
158
+ def _apply_corrections(u_star, v_star, phi, fluid_mask, active_inner, dx, dy, dt, rho):
159
+ ny, nx = u_star.shape
160
+ u_corr = u_star.copy()
161
+ v_corr = v_star.copy()
162
+
163
+ for i in numba.prange(1, ny - 1): # type: ignore
164
+ for j in range(1, nx - 1):
165
+ if active_inner[i, j]:
166
+ u_corr[i, j] = u_star[i, j] - dt / (2.0 * rho * dx) * (phi[i, j+1] - phi[i, j-1])
167
+ v_corr[i, j] = v_star[i, j] - dt / (2.0 * rho * dy) * (phi[i+1, j] - phi[i-1, j])
168
+
169
+ for i in range(ny):
170
+ for j in range(nx):
171
+ if not fluid_mask[i, j]:
172
+ u_corr[i, j] = 0.0
173
+ v_corr[i, j] = 0.0
174
+
175
+ return u_corr, v_corr
176
+
177
+
178
+ def default_velocity_bc(u: np.ndarray, v: np.ndarray, mesh: StructuredMesh2D, step_idx: int):
179
+ """
180
+ Apply no-slip velocity boundary conditions on all outer walls.
181
+
182
+ Args:
183
+ u: Velocity field in the x-direction
184
+ v: Velocity field in the y-direction
185
+ mesh: Mesh object
186
+ step_idx: Step index
187
+ """
188
+ _ = mesh
189
+ _ = step_idx
190
+ u[0, :] = 0.0
191
+ u[-1, :] = 0.0
192
+ u[:, 0] = 0.0
193
+ u[:, -1] = 0.0
194
+
195
+ v[0, :] = 0.0
196
+ v[-1, :] = 0.0
197
+ v[:, 0] = 0.0
198
+ v[:, -1] = 0.0
199
+
200
+
201
+ def default_pressure_bc(p: np.ndarray, mesh: StructuredMesh2D, step_idx: int):
202
+ """
203
+ Apply simple zero-normal-gradient pressure boundaries.
204
+
205
+ Args:
206
+ p: Pressure field
207
+ mesh: Mesh object
208
+ step_idx: Step index
209
+ """
210
+ _ = mesh
211
+ _ = step_idx
212
+ p[:, -1] = p[:, -2]
213
+ p[:, 0] = p[:, 1]
214
+ p[0, :] = p[1, :]
215
+ p[-1, :] = p[-2, :]
216
+ p[-1, -1] = 0.0
217
+
218
+
219
+ def wind_velocity_bc(u: np.ndarray, v: np.ndarray, mesh: StructuredMesh2D, step_idx: int):
220
+ """
221
+ Boundary conditions for a wind flow from West to East.
222
+ - Left boundary: Uniform inflow velocity (e.g., 5 m/s)
223
+ - Right boundary: Outflow (zero gradient)
224
+ - Top/Bottom boundaries: Free-slip
225
+ """
226
+ u[:, 0] = 5.0
227
+ v[:, 0] = 0.0
228
+
229
+ ###Right boundary: outflow (zero normal gradient)
230
+ u[:, -1] = u[:, -2]
231
+ v[:, -1] = v[:, -2]
232
+
233
+ ###Top and bottom boundaries: free slip
234
+ u[0, :] = u[1, :]
235
+ u[-1, :] = u[-2, :]
236
+ v[0, :] = 0.0
237
+ v[-1, :] = 0.0
238
+
239
+
240
+ def wind_pressure_bc(p: np.ndarray, mesh: StructuredMesh2D, step_idx: int):
241
+ """
242
+ Fixed pressure at the outlet to allow mass flux to balance.
243
+ Zero gradient on other boundaries.
244
+ """
245
+ p[:, 0] = p[:, 1]
246
+ p[0, :] = p[1, :]
247
+ p[-1, :] = p[-2, :]
248
+ p[:, -1] = 0.0 ###Dirichlet outlet
249
+
250
+
251
+ from typing import Optional
252
+
253
+ def solve_incomp(
254
+ mesh: StructuredMesh2D,
255
+ cfg: SolverConfig,
256
+ velocity_bc: Optional[Callable[[np.ndarray, np.ndarray, StructuredMesh2D, int], None]] = None,
257
+ pressure_bc: Optional[Callable[[np.ndarray, StructuredMesh2D, int], None]] = None,
258
+ u0: Optional[np.ndarray] = None,
259
+ v0: Optional[np.ndarray] = None,
260
+ p0: Optional[np.ndarray] = None,
261
+ ):
262
+ """
263
+ Advance 2D incompressible flow using a projection method.
264
+
265
+ Args:
266
+ mesh: Mesh object
267
+ cfg: Solver configuration
268
+ velocity_bc: Velocity boundary conditions
269
+ pressure_bc: Pressure boundary conditions
270
+ u0: Initial velocity in the x-direction
271
+ v0: Initial velocity in the y-direction
272
+ p0: Initial pressure
273
+
274
+ Returns:
275
+ u, v, p, history: Solution fields and history
276
+ """
277
+
278
+ velocity_bc = velocity_bc or default_velocity_bc
279
+ pressure_bc = pressure_bc or default_pressure_bc
280
+
281
+ if u0 is None or v0 is None or p0 is None:
282
+ u, v, p = init_field(mesh)
283
+ else:
284
+ u = np.asarray(u0, dtype=float).copy()
285
+ v = np.asarray(v0, dtype=float).copy()
286
+ p = np.asarray(p0, dtype=float).copy()
287
+
288
+ ###Fluid mask and interior mask
289
+ fluid = mesh.fluid_mask
290
+ interior = np.zeros_like(fluid, dtype=bool)
291
+ interior[1:-1, 1:-1] = True
292
+ active = interior & fluid
293
+
294
+ history = []
295
+
296
+ import time
297
+ import logging
298
+ logger = logging.getLogger(__name__)
299
+ logger.info("Iteration\tAvgResidual\tRemainingTime(s)")
300
+ start_time = time.time()
301
+
302
+ bc_type = 1 if pressure_bc is wind_pressure_bc else 0
303
+
304
+ ###Time-stepping loop
305
+ for n in range(cfg.nt):
306
+ un = u.copy()
307
+ vn = v.copy()
308
+
309
+ u_star, v_star = _compute_intermediate_velocity(
310
+ un, vn, p, fluid, active, mesh.dx, mesh.dy, cfg.dt, cfg.rho, cfg.nu, cfg.fx, cfg.fy
311
+ )
312
+
313
+ ###Apply boundary conditions for u and v
314
+ velocity_bc(u_star, v_star, mesh, n)
315
+
316
+ ###Compute the source term for the pressure Poisson equation
317
+ b = cfg.rho / cfg.dt * divergence(u_star, v_star, mesh.dx, mesh.dy)
318
+ b[~fluid] = 0.0
319
+
320
+ ###Solve for the pressure correction (phi)
321
+ phi = np.zeros_like(p)
322
+ phi = pressure_poisson(phi, b, fluid, mesh.dx, mesh.dy, cfg.nit, bc_type)
323
+
324
+ ###Update velocity and pressure fields
325
+ u, v = _apply_corrections(u_star, v_star, phi, fluid, active, mesh.dx, mesh.dy, cfg.dt, cfg.rho)
326
+ p = p + phi
327
+
328
+ ###Apply boundary conditions for u, v, and p
329
+ velocity_bc(u, v, mesh, n)
330
+ pressure_bc(p, mesh, n)
331
+
332
+ ###Record divergence for monitoring
333
+ if (n + 1) % cfg.monitor == 0 or n == 0:
334
+ div = divergence(u, v, mesh.dx, mesh.dy)
335
+ div[~fluid] = 0.0
336
+ residual = float(np.sum(np.abs(div)) / max(1, np.sum(fluid)))
337
+ history.append((n + 1, residual))
338
+
339
+ elapsed = time.time() - start_time
340
+ time_per_iter = elapsed / (n + 1)
341
+ remaining_time = time_per_iter * (cfg.nt - (n + 1))
342
+
343
+ msg = f"Iter: {n+1}/{cfg.nt} | Avg Residual: {residual:.6e} | Remaining Time: {remaining_time:.1f}s"
344
+ logger.info(msg)
345
+ # We skip writing to log_file.
346
+
347
+
348
+ return {
349
+ "x": mesh.x,
350
+ "y": mesh.y,
351
+ "X": mesh.X,
352
+ "Y": mesh.Y,
353
+ "fluid_mask": mesh.fluid_mask,
354
+ "u": u,
355
+ "v": v,
356
+ "p": p,
357
+ "history": history,
358
+ "dx": mesh.dx,
359
+ "dy": mesh.dy,
360
+ }
incompy2d/mesh_util.py ADDED
@@ -0,0 +1,128 @@
1
+ """Mesh generation and basic loading utilities for 2D structured grids."""
2
+
3
+ import numpy as np
4
+
5
+ class StructuredMesh2D:
6
+ """Container for a 2D structured mesh and fluid/solid mask."""
7
+
8
+ def __init__(self, x, y, X, Y, dx, dy, fluid_mask):
9
+ """Store mesh coordinates, spacing, and fluid mask arrays.
10
+
11
+ Args:
12
+ x (np.ndarray): 1D array of x-coordinates
13
+ y (np.ndarray): 1D array of y-coordinates
14
+ X (np.ndarray): 2D grid of x-coordinates
15
+ Y (np.ndarray): 2D grid of y-coordinates
16
+ dx (float): Grid spacing in the x-direction
17
+ dy (float): Grid spacing in the y-direction
18
+ fluid_mask (np.ndarray): Boolean mask indicating fluid cells
19
+
20
+ """
21
+ self.x = x
22
+ self.y = y
23
+ self.X = X
24
+ self.Y = Y
25
+ self.dx = dx
26
+ self.dy = dy
27
+ self.fluid_mask = fluid_mask
28
+ self.nx = X.shape[1]
29
+ self.ny = X.shape[0]
30
+
31
+
32
+ def uniform_msh(nx, ny, lx, ly):
33
+ """Create uniform rectangular mesh. All cells marked as fluid
34
+
35
+ Args:
36
+ nx (int): # grid points in x-direction
37
+ ny (int): # grid points in y-direction
38
+ lx (float): Domain length in x-direction
39
+ ly (float): Domain length in y-direction
40
+ Returns:
41
+ StructuredMesh2D: Mesh object (coords, spacing, fluid mask)
42
+
43
+ """
44
+ dx = lx / (nx - 1)
45
+ dy = ly / (ny - 1)
46
+
47
+ ###Create linearly spaced xy-coord
48
+ x = np.linspace(0, lx, nx)
49
+ y = np.linspace(0, ly, ny)
50
+ xg, yg = np.meshgrid(x, y)
51
+
52
+ ###Mark all cells as fluid
53
+ fluid_mask = np.ones((ny, nx), dtype=bool)
54
+ return StructuredMesh2D(x=x, y=y, X=xg, Y=yg, dx=dx, dy=dy, fluid_mask=fluid_mask)
55
+
56
+
57
+ def load_npz(npz_path):
58
+ """Load x, y and optional fluid_mask from .npz file
59
+
60
+ Args:
61
+ npz_path (str): Path to the .npz file containing the mesh
62
+ Returns:
63
+ StructuredMesh2D: Mesh object containing coordinates, spacing, and fluid mask
64
+ Raises:
65
+ ValueError: If x or y are not 1D arrays or if the mesh is not uniformly spaced
66
+
67
+ """
68
+ data = np.load(npz_path)
69
+ x = np.asarray(data["x"], dtype=float)
70
+ y = np.asarray(data["y"], dtype=float)
71
+
72
+ if x.ndim != 1 or y.ndim != 1:
73
+ raise ValueError("x and y must be 1D arrays")
74
+
75
+ dxs = np.diff(x)
76
+ dys = np.diff(y)
77
+ if not np.allclose(dxs, dxs[0]) or not np.allclose(dys, dys[0]):
78
+ raise ValueError("Current solver requires uniformly spaced x and y")
79
+
80
+ xg, yg = np.meshgrid(x, y)
81
+ ny, nx = yg.shape
82
+
83
+ if "fluid_mask" in data:
84
+ fluid_mask = np.asarray(data["fluid_mask"], dtype=bool)
85
+ if fluid_mask.shape != (ny, nx):
86
+ raise ValueError("fluid_mask shape must match (ny, nx)")
87
+ else:
88
+ fluid_mask = np.ones((ny, nx), dtype=bool)
89
+
90
+ return StructuredMesh2D(
91
+ x=x,
92
+ y=y,
93
+ X=xg,
94
+ Y=yg,
95
+ dx=float(dxs[0]),
96
+ dy=float(dys[0]),
97
+ fluid_mask=fluid_mask,
98
+ )
99
+
100
+ def msh_png(mesh, output_path):
101
+ """
102
+ Export a visualization of the mesh grid to a PNG file.
103
+
104
+ Args:
105
+ mesh (StructuredMesh2D): The mesh object to visualize.
106
+ output_path (str): The file path where the PNG will be saved.
107
+ """
108
+ import matplotlib.pyplot as plt
109
+ import os
110
+
111
+ # Ensure directory exists
112
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
113
+
114
+ plt.figure(figsize=(10, 8))
115
+ # Plot horizontal grid lines
116
+ for i in range(mesh.ny):
117
+ plt.plot(mesh.X[i, :], mesh.Y[i, :], color='gray', lw=0.5, alpha=0.5)
118
+ # Plot vertical grid lines
119
+ for j in range(mesh.nx):
120
+ plt.plot(mesh.X[:, j], mesh.Y[:, j], color='gray', lw=0.5, alpha=0.5)
121
+
122
+ plt.title(f"Structured Mesh ({mesh.nx} x {mesh.ny})")
123
+ plt.xlabel("X")
124
+ plt.ylabel("Y")
125
+ plt.axis('equal')
126
+ plt.tight_layout()
127
+ plt.savefig(output_path, dpi=300)
128
+ plt.close()
incompy2d/post.py ADDED
@@ -0,0 +1,200 @@
1
+ import numpy as np
2
+ import geopandas as gpd
3
+ import matplotlib.pyplot as plt
4
+ from pathlib import Path
5
+
6
+ def draw_buildings(gdf):
7
+ if gdf is None:
8
+ return
9
+ for geom in gdf.geometry:
10
+ if geom is None:
11
+ continue
12
+ if geom.geom_type == 'Polygon':
13
+ x, y = geom.exterior.xy
14
+ plt.plot(x, y, color='black', linewidth=1)
15
+ elif geom.geom_type == 'MultiPolygon':
16
+ for poly in geom.geoms:
17
+ x, y = poly.exterior.xy
18
+ plt.plot(x, y, color='black', linewidth=1)
19
+
20
+ def main(output_dir=None):
21
+ if output_dir is None:
22
+ output_dir = Path.cwd() / "outputs"
23
+ else:
24
+ output_dir = Path(output_dir)
25
+ sim_data_file = output_dir / "sim_data.npz"
26
+ shifted_geojson = output_dir / "shifted_buildings.geojson"
27
+
28
+ if not sim_data_file.exists():
29
+ raise FileNotFoundError(f"Simulation data not found at {sim_data_file}")
30
+
31
+ print(f"Loading simulation data from {sim_data_file}...")
32
+ # Allow pickle might be needed if objects are stored, but our dict is pure numpy arrays
33
+ res = np.load(sim_data_file)
34
+
35
+ print(f"Loading building geometries from {shifted_geojson}...")
36
+ if not shifted_geojson.exists():
37
+ print(f"Warning: Shifted geojson not found at {shifted_geojson}, buildings won't be drawn.")
38
+ gdf = None
39
+ else:
40
+ gdf = gpd.read_file(shifted_geojson)
41
+
42
+ print("Plotting results...")
43
+ plt.figure(figsize=(12, 10))
44
+
45
+ # Compute velocity magnitude
46
+ vel_mag = np.sqrt(res['u']**2 + res['v']**2)
47
+
48
+ # Mask out the solid regions in the plot
49
+ fluid_mask = res['fluid_mask']
50
+ vel_mag[~fluid_mask] = np.nan
51
+
52
+ # Create contour plot
53
+ contour = plt.contourf(res['X'], res['Y'], vel_mag, levels=100, cmap='turbo', antialiased=True)
54
+ plt.colorbar(contour, label='Velocity Magnitude (m/s)')
55
+
56
+ # Draw building boundaries
57
+ draw_buildings(gdf)
58
+
59
+ plt.title("Wind Flow Around RWTH Aachen Buildings")
60
+ plt.xlabel("X [m]")
61
+ plt.ylabel("Y [m]")
62
+ plt.axis('equal')
63
+
64
+ output_png = output_dir / "rwth_flow_result.png"
65
+ plt.savefig(output_png, dpi=300, bbox_inches='tight')
66
+ print(f"Saved result image to {output_png}")
67
+
68
+ print("Plotting vector field...")
69
+ plt.figure(figsize=(12, 10))
70
+
71
+ # Subsample for quiver plot to avoid overcrowding
72
+ step = 5
73
+ X_sub = res['X'][::step, ::step]
74
+ Y_sub = res['Y'][::step, ::step]
75
+ u_sub = res['u'][::step, ::step]
76
+ v_sub = res['v'][::step, ::step]
77
+ mask_sub = fluid_mask[::step, ::step]
78
+
79
+ # Mask solid regions
80
+ u_sub = np.where(mask_sub, u_sub, np.nan)
81
+ v_sub = np.where(mask_sub, v_sub, np.nan)
82
+
83
+ # Draw buildings
84
+ draw_buildings(gdf)
85
+
86
+ # Draw vector field
87
+ # Calculate velocity magnitude for color mapping the arrows
88
+ mag_sub = np.sqrt(u_sub**2 + v_sub**2)
89
+
90
+ # Normalize vectors so all arrows have a length of 1 (showing only direction)
91
+ u_norm = u_sub / (mag_sub + 1e-10)
92
+ v_norm = v_sub / (mag_sub + 1e-10)
93
+
94
+ plt.quiver(X_sub, Y_sub, u_norm, v_norm, mag_sub, cmap='turbo', scale=75, width=0.002)
95
+
96
+ plt.title("Wind Vector Field Around RWTH Aachen Buildings")
97
+ plt.xlabel("X [m]")
98
+ plt.ylabel("Y [m]")
99
+ plt.axis('equal')
100
+
101
+ output_vector_png = output_dir / "rwth_vector_field.png"
102
+ plt.savefig(output_vector_png, dpi=300, bbox_inches='tight')
103
+ print(f"Saved vector field image to {output_vector_png}")
104
+
105
+ print("Plotting streamlines...")
106
+ plt.figure(figsize=(12, 10))
107
+
108
+ # We use the full resolution grids for streamplot for smooth lines
109
+ u_full = res['u'].copy()
110
+ v_full = res['v'].copy()
111
+
112
+ # Mask solid regions
113
+ u_full[~fluid_mask] = np.nan
114
+ v_full[~fluid_mask] = np.nan
115
+
116
+ # Calculate magnitude for color mapping
117
+ mag_full = np.sqrt(u_full**2 + v_full**2)
118
+
119
+ # Draw buildings
120
+ draw_buildings(gdf)
121
+
122
+ # Draw streamlines
123
+ # streamplot expects 1D arrays for x and y coordinates
124
+ strm = plt.streamplot(res['X'][0, :], res['Y'][:, 0], u_full, v_full,
125
+ color=mag_full, cmap='turbo', density=5.0, linewidth=1)
126
+ plt.colorbar(strm.lines, label='Velocity Magnitude (m/s)')
127
+
128
+ plt.title("Wind Streamlines Around RWTH Aachen Buildings")
129
+ plt.xlabel("X [m]")
130
+ plt.ylabel("Y [m]")
131
+ plt.axis('equal')
132
+
133
+ output_stream_png = output_dir / "rwth_streamlines.png"
134
+ plt.savefig(output_stream_png, dpi=300, bbox_inches='tight')
135
+ print(f"Saved streamlines image to {output_stream_png}")
136
+
137
+ print("Plotting convergence history...")
138
+ log_file = Path(__file__).parent / "solver.log"
139
+ if log_file.exists():
140
+ try:
141
+ data = np.loadtxt(log_file, skiprows=1)
142
+ if data.size > 0:
143
+ # If there's only one row, reshape it to 2D
144
+ if data.ndim == 1:
145
+ data = data.reshape(1, -1)
146
+ iterations = data[:, 0]
147
+ residuals = data[:, 1]
148
+
149
+ plt.figure(figsize=(10, 6))
150
+ plt.plot(iterations, residuals, 'b-', linewidth=2)
151
+ plt.yscale('log')
152
+ plt.title("Solver Convergence History")
153
+ plt.xlabel("Iteration")
154
+ plt.ylabel("Average Divergence (Residual)")
155
+ plt.grid(True, which="both", ls="--")
156
+
157
+ output_conv_png = output_dir / "rwth_convergence.png"
158
+ plt.savefig(output_conv_png, dpi=300, bbox_inches='tight')
159
+ print(f"Saved convergence history to {output_conv_png}")
160
+ else:
161
+ print("solver.log is empty.")
162
+ except Exception as e:
163
+ print(f"Could not plot solver.log: {e}")
164
+ else:
165
+ print(f"solver.log not found at {log_file}")
166
+
167
+ print("Plotting geometry mask...")
168
+ plt.figure(figsize=(12, 10))
169
+ solid_int = np.where(fluid_mask, 0, 1)
170
+ plt.pcolormesh(res['X'], res['Y'], solid_int, cmap='binary', shading='nearest')
171
+ draw_buildings(gdf)
172
+ plt.title("Building Geometry Mask")
173
+ plt.xlabel("X [m]")
174
+ plt.ylabel("Y [m]")
175
+ plt.axis('equal')
176
+
177
+ output_geom_png = output_dir / "geometry_mask.png"
178
+ plt.savefig(output_geom_png, dpi=300, bbox_inches='tight')
179
+ print(f"Saved geometry mask image to {output_geom_png}")
180
+
181
+ print("Plotting mesh grid (with subtracted buildings)...")
182
+ plt.figure(figsize=(12, 10))
183
+ # Draw grid lines by plotting an empty grid with edgecolors, using subset if too dense? No, we plot all.
184
+ # We can plot grid lines everywhere as lightgray, and overlay black solid mask
185
+ plt.pcolormesh(res['X'], res['Y'], np.zeros_like(fluid_mask), cmap='binary', edgecolors='lightgray', linewidth=0.1, shading='nearest', vmin=0, vmax=1)
186
+ solid_nan = np.where(fluid_mask, np.nan, 1)
187
+ plt.pcolormesh(res['X'], res['Y'], solid_nan, cmap='binary', shading='nearest', vmin=0, vmax=1)
188
+ draw_buildings(gdf)
189
+
190
+ plt.title("Mesh Grid (Subtracted Buildings)")
191
+ plt.xlabel("X [m]")
192
+ plt.ylabel("Y [m]")
193
+ plt.axis('equal')
194
+
195
+ output_mesh_png = output_dir / "mesh_grid.png"
196
+ plt.savefig(output_mesh_png, dpi=300, bbox_inches='tight')
197
+ print(f"Saved mesh grid image to {output_mesh_png}")
198
+
199
+ if __name__ == "__main__":
200
+ main()
incompy2d/run.py ADDED
@@ -0,0 +1,115 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from pathlib import Path
4
+
5
+ # Local solver imports
6
+ from .mesh_util import msh_png
7
+ from .geom_util import geom_geojson, geom_png
8
+ from .incomp_2d import solve_incomp, SolverConfig, wind_pressure_bc
9
+
10
+ def get_input(prompt, default, cast_type):
11
+ user_input = input(f"{prompt} (Default: {default}): ").strip()
12
+ if not user_input:
13
+ return default
14
+ try:
15
+ return cast_type(user_input)
16
+ except ValueError:
17
+ print(f"Invalid input. Using default: {default}")
18
+ return default
19
+
20
+ def main():
21
+ print("\n========= Incomp2D Solver Configuration =========")
22
+ default_geojson = "overture_bld.geojson"
23
+ geojson_in = get_input("GeoJSON path", default_geojson, str)
24
+ dx_in = get_input("Grid spacing dx [m]", 1.0, float)
25
+ padding_in = get_input("Domain padding [m]", 30.0, float)
26
+
27
+ rho_in = get_input("Air density (rho) [kg/m^3]", 1.225, float)
28
+ nu_in = get_input("Kinematic viscosity (nu) [m^2/s]", 1.5e-5, float)
29
+ wind_u = get_input("Wind velocity X (u) [m/s])", 5.0, float)
30
+ wind_v = get_input("Wind velocity Y (v) [m/s])", 0.0, float)
31
+
32
+ ###Estimate CFL and recommend [dt]
33
+ wind_speed = np.sqrt(wind_u**2 + wind_v**2)
34
+ max_local_vel = wind_speed * 2.5 ###Heuristic for flow acceleration around buildings
35
+ dx_est = dx_in ###Use user-specified grid spacing
36
+ dt_recommended = dx_est / max_local_vel if max_local_vel > 0 else 0.01
37
+ dt_recommended = round(dt_recommended * 0.9, 4) ###Add 10% safety margin
38
+
39
+ print(f"\n [INFO] Estimated max local velocity around buildings: ~{max_local_vel:.1f} m/s")
40
+ print(f" [INFO] To maintain CFL < 1.0 (stability), recommended dt <= {dt_recommended} s\n")
41
+
42
+ dt_in = get_input("Timestep size (dt) [s]", dt_recommended, float)
43
+
44
+ ###Check CFL violation
45
+ actual_cfl = max_local_vel * dt_in / dx_est
46
+ if actual_cfl > 1.0:
47
+ print(f"\n [WARNING] CRITICAL: Your dt={dt_in} s gives an estimated CFL of {actual_cfl:.2f} > 1.0!")
48
+ print(" [WARNING] The simulation is highly likely to blow up (Residual = NaN).")
49
+ print(" [WARNING] Consider pressing Ctrl+C and lowering your timestep.\n")
50
+
51
+ nt_in = get_input("Number of timesteps (nt)", 20000, int)
52
+ nit_in = get_input("Pressure Poisson iterations (nit)", 50, int)
53
+ monitor_in = get_input("Print to console every (iter)", 100, int)
54
+ print("============================================\n")
55
+
56
+ def custom_velocity_bc(u: np.ndarray, v: np.ndarray, mesh, step_idx: int):
57
+ u[:, 0] = wind_u
58
+ v[:, 0] = wind_v
59
+ u[:, -1] = u[:, -2]
60
+ v[:, -1] = v[:, -2]
61
+ u[0, :] = u[1, :]
62
+ u[-1, :] = u[-2, :]
63
+ v[0, :] = 0.0
64
+ v[-1, :] = 0.0
65
+
66
+ ###1-5. Load GeoJSON, project, pad, and rasterize onto mesh
67
+ geojson_path = Path(geojson_in)
68
+ print(f"Generating mesh and geometry from {geojson_path}")
69
+
70
+ mesh, gdf = geom_geojson(geojson_path, dx=dx_in, padding=padding_in)
71
+
72
+ ###Export mesh and geometry visualizations
73
+ output_dir = Path.cwd() / "outputs"
74
+ output_dir.mkdir(exist_ok=True)
75
+ msh_png(mesh, str(output_dir / "mesh_grid.png"))
76
+ geom_png(mesh, str(output_dir / "geometry_mask.png"))
77
+ print(f"Exported mesh and geometry PNGs to {output_dir}")
78
+
79
+ ###6. Configure and run the solver
80
+ print("Setting up the NS solver...")
81
+ cfg = SolverConfig(
82
+ rho=rho_in,
83
+ nu=nu_in,
84
+ dt=dt_in,
85
+ nt=nt_in,
86
+ nit=nit_in,
87
+ monitor=monitor_in
88
+ )
89
+
90
+ print("Running incompressible Navier-Stokes solver (this might take several minutes)...")
91
+ res = solve_incomp(
92
+ mesh=mesh,
93
+ cfg=cfg,
94
+ velocity_bc=custom_velocity_bc,
95
+ pressure_bc=wind_pressure_bc
96
+ )
97
+
98
+ ###7. Postprocessing Export
99
+ print("Exporting simulation data for postprocessing...")
100
+ output_dir = Path.cwd() / "outputs"
101
+ output_dir.mkdir(exist_ok=True)
102
+
103
+ ###Save the shifted geometries to a geojson
104
+ shifted_geojson = output_dir / "shifted_buildings.geojson"
105
+ gdf.to_file(shifted_geojson, driver='GeoJSON')
106
+
107
+ ###Save the simulation results
108
+ sim_data_file = output_dir / "sim_data.npz"
109
+ np.savez_compressed(sim_data_file, **res)
110
+ print(f"Saved simulation data to {sim_data_file}")
111
+ print(f"Saved shifted building footprints to {shifted_geojson}")
112
+ print("Run post.py to generate visualizations.")
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: incompy2d
3
+ Version: 0.1.0
4
+ Summary: A 2D incompressible Navier-Stokes CFD solver in Python
5
+ Project-URL: Repository, https://gitlab.git.nrw/phuong-nam.nguyen/sce_incompy2d
6
+ Author-email: Phuong-Nam Nguyen <phuong-nam.nguyen@rwth-aachen.de>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: numba
11
+ Requires-Dist: numpy
12
+ Provides-Extra: dev
13
+ Requires-Dist: black; extra == 'dev'
14
+ Requires-Dist: cookiecutter; extra == 'dev'
15
+ Requires-Dist: geopandas; extra == 'dev'
16
+ Requires-Dist: ipympl; extra == 'dev'
17
+ Requires-Dist: jupyterlab; extra == 'dev'
18
+ Requires-Dist: matplotlib; extra == 'dev'
19
+ Requires-Dist: meshio; extra == 'dev'
20
+ Requires-Dist: osmnx; extra == 'dev'
21
+ Requires-Dist: overturemaps; extra == 'dev'
22
+ Requires-Dist: pandas; extra == 'dev'
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: pytest-cov; extra == 'dev'
25
+ Requires-Dist: scikit-learn; extra == 'dev'
26
+ Requires-Dist: scipy; extra == 'dev'
27
+ Requires-Dist: shapely; extra == 'dev'
28
+ Requires-Dist: sphinx; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: sphinx; extra == 'docs'
31
+ Provides-Extra: geo
32
+ Requires-Dist: geopandas; extra == 'geo'
33
+ Requires-Dist: meshio; extra == 'geo'
34
+ Requires-Dist: osmnx; extra == 'geo'
35
+ Requires-Dist: overturemaps; extra == 'geo'
36
+ Requires-Dist: shapely; extra == 'geo'
37
+ Provides-Extra: test
38
+ Requires-Dist: pytest; extra == 'test'
39
+ Requires-Dist: pytest-cov; extra == 'test'
40
+ Provides-Extra: viz
41
+ Requires-Dist: matplotlib; extra == 'viz'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # incompy2d
45
+
46
+ A 2D incompressible Navier-Stokes CFD solver in Python.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install incompy2d
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ Please refer to the examples in the documentation.
@@ -0,0 +1,12 @@
1
+ incompy2d/__init__.py,sha256=njQhdVACwpF07SEvfzXLwiv-qUfmOoCXk4UYzgqA2tQ,606
2
+ incompy2d/fetch_overture.py,sha256=X6rMLEJowT_-JzQabDWzp3qO4IoJh5mi2gIcdJxW9hI,3071
3
+ incompy2d/geom_util.py,sha256=mtug0W2Chq5exy8MM4Hl0k6L0ARAAXjiADFQbPRyQKQ,4151
4
+ incompy2d/incomp_2d.py,sha256=vzfum1VI4HTK5dLR1bglxC-siOqyz_jM1CHd-NM0B1k,11444
5
+ incompy2d/mesh_util.py,sha256=r_txpBA8L9OSCFNURAensg2QMSBAt5U8Sr8zSsgp3Jo,3918
6
+ incompy2d/post.py,sha256=M-8NswRRNUITxlY0Iq_j9aUhBcE6VtwUKGYC2tug49o,7302
7
+ incompy2d/run.py,sha256=ZGKp74DCDOLUWbIzgtiJP8TJrgeuTNMS49VVfeROzfU,4503
8
+ incompy2d-0.1.0.dist-info/METADATA,sha256=FJk6QHSRn1TYJAEv-NhaCy-2dM6VlAD-3IXWQQyrZkM,1692
9
+ incompy2d-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ incompy2d-0.1.0.dist-info/entry_points.txt,sha256=yreDzzgbZWxHSKNgo_ZGsJVdUZ5i4AOI6JImWCPAJcA,53
11
+ incompy2d-0.1.0.dist-info/licenses/LICENSE,sha256=dr0kk7GMiSG5WKjro9K-Vd0S8d61NuKMoCExZzGNLnA,1074
12
+ incompy2d-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ incompy2d-run = incompy2d.run:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phuong-Nam Nguyen
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.