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 +35 -0
- incompy2d/fetch_overture.py +91 -0
- incompy2d/geom_util.py +141 -0
- incompy2d/incomp_2d.py +360 -0
- incompy2d/mesh_util.py +128 -0
- incompy2d/post.py +200 -0
- incompy2d/run.py +115 -0
- incompy2d-0.1.0.dist-info/METADATA +56 -0
- incompy2d-0.1.0.dist-info/RECORD +12 -0
- incompy2d-0.1.0.dist-info/WHEEL +4 -0
- incompy2d-0.1.0.dist-info/entry_points.txt +2 -0
- incompy2d-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|