import osimport numpy as npimport pandas as pdimport matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as pltfrom scipy.stats import gaussian_kdefrom mpl_toolkits.mplot3d.art3d import Poly3DCollectionnp.random.seed(2026)plt.rcParams["font.family"] = "Times New Roman"plt.rcParams["axes.unicode_minus"] = Falseplt.rcParams["figure.dpi"] = 160plt.rcParams["savefig.dpi"] = 500out_dir = "dfn_joint_network_results"os.makedirs(out_dir, exist_ok=True)# 岩体立方体尺寸,单位可理解为 mL = 100.0# 连接判定半径放大系数# 如果连通边太少,可以调大,例如 1.45 或 1.60CONNECTION_RADIUS_FACTOR = 1.30# 若真实相交边太少,自动放宽一次MIN_EDGE_COUNT = 20def normalize(v): v = np.asarray(v, dtype=float) n = np.linalg.norm(v) if n < 1e-12: return v return v / ndef normal_from_dipdir_dip(dipdir_deg, dip_deg): """ 根据倾向 dip direction 和倾角 dip 计算平面法向量。 坐标系: x = East y = North z = Up 返回下半球极点方向,z 分量为负。 """ alpha = np.deg2rad(dipdir_deg) dip = np.deg2rad(dip_deg) n = np.array([ np.sin(dip) * np.sin(alpha), np.sin(dip) * np.cos(alpha), -np.cos(dip) ]) return normalize(n)def plane_basis_from_normal(n): """ 给定平面法向量,构造平面内两个正交基向量。 """ n = normalize(n) ref = np.array([0.0, 0.0, 1.0]) if abs(np.dot(ref, n)) > 0.90: ref = np.array([1.0, 0.0, 0.0]) u = normalize(np.cross(n, ref)) v = normalize(np.cross(n, u)) return u, vdef ellipse_polygon(center, u, v, a, b, n_points=72): """ 生成一个椭圆节理盘面的三维多边形点。 """ theta = np.linspace(0, 2 * np.pi, n_points) pts = ( center[None, :] + a * np.cos(theta)[:, None] * u[None, :] + b * np.sin(theta)[:, None] * v[None, :] ) return ptsdef draw_cube(ax, L_box): """ 绘制三维立方体边框。 """ corners = np.array([ [0, 0, 0], [L_box, 0, 0], [L_box, L_box, 0], [0, L_box, 0], [0, 0, L_box], [L_box, 0, L_box], [L_box, L_box, L_box], [0, L_box, L_box] ]) edges = [ (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7) ] for i, j in edges: ax.plot( [corners[i, 0], corners[j, 0]], [corners[i, 1], corners[j, 1]], [corners[i, 2], corners[j, 2]], color="black", linewidth=0.8, alpha=0.65 )def stereonet_project_pole(dipdir_deg, dip_deg): """ Schmidt 等面积赤平投影中的极点投影。 返回投影坐标 x, y。 """ n = normal_from_dipdir_dip(dipdir_deg, dip_deg) # 与下半球竖直向下方向的夹角 theta = np.arccos(np.clip(-n[2], -1.0, 1.0)) # 方位角 trend = np.arctan2(n[0], n[1]) # Schmidt net,外圈半径归一为 1 r = np.sqrt(2.0) * np.sin(theta / 2.0) x = r * np.sin(trend) y = r * np.cos(trend) return x, ydef disks_intersect_approx(c1, n1, r1, c2, n2, r2): """ 近似判断两个圆盘是否相交。 椭圆节理在这里用等效连接半径近似。 """ c1 = np.asarray(c1, dtype=float) c2 = np.asarray(c2, dtype=float) n1 = normalize(n1) n2 = normalize(n2) direction = np.cross(n1, n2) dn = np.linalg.norm(direction) # 近似平行节理 if dn < 1e-6: plane_gap = abs(np.dot(n1, c2 - c1)) if plane_gap > 0.25 * min(r1, r2): return False delta = c2 - c1 delta_in_plane = delta - np.dot(delta, n1) * n1 center_dist = np.linalg.norm(delta_in_plane) return center_dist <= 0.85 * (r1 + r2) d = direction / dn # 两平面交线:n1·x = n1·c1, n2·x = n2·c2, d·x = 0 A = np.vstack([n1, n2, d]) b = np.array([ np.dot(n1, c1), np.dot(n2, c2), 0.0 ]) try: p0 = np.linalg.solve(A, b) except np.linalg.LinAlgError: return False intervals = [] for c, r in [(c1, r1), (c2, r2)]: t0 = np.dot(c - p0, d) closest = p0 + t0 * d dist2 = np.sum((closest - c) ** 2) if dist2 > r ** 2: return False half_len = np.sqrt(max(r ** 2 - dist2, 0.0)) intervals.append((t0 - half_len, t0 + half_len)) t_start = max(intervals[0][0], intervals[1][0]) t_end = min(intervals[0][1], intervals[1][1]) return t_start <= t_enddef build_edges(frac_df, radius_factor): """ 建立节理连通边。 """ edges = [] centers = frac_df[["center_x", "center_y", "center_z"]].values normals = frac_df[["normal_x", "normal_y", "normal_z"]].values r_conn = radius_factor * np.sqrt( frac_df["semi_major_axis"] * frac_df["semi_minor_axis"] ).values n_frac = len(frac_df) for i in range(n_frac): for j in range(i + 1, n_frac): connected = disks_intersect_approx( centers[i], normals[i], r_conn[i], centers[j], normals[j], r_conn[j] ) if connected: edges.append((i, j)) return edgesdef connected_components(n_nodes, edge_list): """ 简单并查集计算连通分量。 """ parent = np.arange(n_nodes) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): ra = find(a) rb = find(b) if ra != rb: parent[rb] = ra for i, j in edge_list: union(i, j) roots = np.array([find(i) for i in range(n_nodes)]) unique_roots = {r: k + 1 for k, r in enumerate(np.unique(roots))} comp_ids = np.array([unique_roots[r] for r in roots]) comp_sizes = {} for cid in comp_ids: comp_sizes[cid] = comp_sizes.get(cid, 0) + 1 return comp_ids, comp_sizesdef draw_stereonet_grid(ax): """ 绘制简易 Schmidt 赤平投影网。 """ outer = plt.Circle( (0, 0), 1.0, edgecolor="black", facecolor="none", linewidth=1.2 ) ax.add_patch(outer) # 倾角环 for dip in [15, 30, 45, 60, 75]: r = np.sqrt(2.0) * np.sin(np.deg2rad(dip) / 2.0) circ = plt.Circle( (0, 0), r, edgecolor="gray", facecolor="none", linewidth=0.6, alpha=0.35 ) ax.add_patch(circ) ax.text( r / np.sqrt(2), r / np.sqrt(2), f"{dip}°", fontsize=8, color="gray", ha="left", va="bottom" ) # 方位线 for az in np.arange(0, 360, 30): a = np.deg2rad(az) ax.plot( [0, np.sin(a)], [0, np.cos(a)], color="gray", linewidth=0.5, alpha=0.28 ) ax.text(0, 1.08, "N", ha="center", va="center", fontsize=11) ax.text(1.08, 0, "E", ha="center", va="center", fontsize=11) ax.text(0, -1.08, "S", ha="center", va="center", fontsize=11) ax.text(-1.08, 0, "W", ha="center", va="center", fontsize=11) ax.set_aspect("equal") ax.set_xlim(-1.12, 1.12) ax.set_ylim(-1.12, 1.12) ax.axis("off")# 生成三组节理数据joint_sets = [ { "set_id": "J1", "name": "J1 steep NE", "n": 42, "dipdir_mean": 45, "dipdir_std": 10, "dip_mean": 68, "dip_std": 7, "radius_mean": 12.0, "color": "#4C72B0" }, { "set_id": "J2", "name": "J2 steep SE", "n": 40, "dipdir_mean": 135, "dipdir_std": 11, "dip_mean": 72, "dip_std": 8, "radius_mean": 11.0, "color": "#DD8452" }, { "set_id": "J3", "name": "J3 low-angle", "n": 32, "dipdir_mean": 285, "dipdir_std": 18, "dip_mean": 28, "dip_std": 8, "radius_mean": 15.0, "color": "#55A868" }]records = []polygons = []label_id = 1for js in joint_sets: for _ in range(js["n"]): dipdir = (js["dipdir_mean"] + np.random.normal(0, js["dipdir_std"])) % 360 dip = np.clip(js["dip_mean"] + np.random.normal(0, js["dip_std"]), 8, 88) center = np.random.uniform(8, L - 8, size=3) # 半长轴、半短轴 a = np.random.lognormal(mean=np.log(js["radius_mean"]), sigma=0.28) a = np.clip(a, 5.5, 24.0) b = a * np.random.uniform(0.55, 0.95) n = normal_from_dipdir_dip(dipdir, dip) u, v = plane_basis_from_normal(n) # 在平面内随机旋转椭圆方向 phi = np.random.uniform(0, 2 * np.pi) u2 = np.cos(phi) * u + np.sin(phi) * v v2 = -np.sin(phi) * u + np.cos(phi) * v poly = ellipse_polygon(center, u2, v2, a, b, n_points=72) polygons.append(poly) stereo_x, stereo_y = stereonet_project_pole(dipdir, dip) strike = (dipdir - 90) % 360 trace_length = 2.0 * a area = np.pi * a * b records.append({ "label": label_id, "set_id": js["set_id"], "set_name": js["name"], "center_x": center[0], "center_y": center[1], "center_z": center[2], "dip_direction_deg": dipdir, "dip_deg": dip, "strike_deg": strike, "semi_major_axis": a, "semi_minor_axis": b, "trace_length": trace_length, "fracture_area": area, "normal_x": n[0], "normal_y": n[1], "normal_z": n[2], "basis_u_x": u2[0], "basis_u_y": u2[1], "basis_u_z": u2[2], "basis_v_x": v2[0], "basis_v_y": v2[1], "basis_v_z": v2[2], "stereo_x": stereo_x, "stereo_y": stereo_y }) label_id += 1frac_df = pd.DataFrame(records)#计算组内最近间距nearest_spacing = np.full(len(frac_df), np.nan)for sid in frac_df["set_id"].unique(): idx = np.where(frac_df["set_id"].values == sid)[0] centers = frac_df.loc[idx, ["center_x", "center_y", "center_z"]].values for local_i, global_i in enumerate(idx): if len(idx) <= 1: nearest_spacing[global_i] = np.nan continue dist = np.linalg.norm(centers - centers[local_i], axis=1) dist[local_i] = np.inf nearest_spacing[global_i] = np.min(dist)frac_df["nearest_same_set_spacing"] = nearest_spacing# 建立节理连通网络edges = build_edges(frac_df, CONNECTION_RADIUS_FACTOR)if len(edges) < MIN_EDGE_COUNT: print("提示:初始连通边较少,已自动放宽连接半径。") edges = build_edges(frac_df, CONNECTION_RADIUS_FACTOR * 1.35)n_frac = len(frac_df)degree = np.zeros(n_frac, dtype=int)for i, j in edges: degree[i] += 1 degree[j] += 1component_ids, component_sizes = connected_components(n_frac, edges)frac_df["degree"] = degreefrac_df["component_id"] = component_idsfrac_df["component_size"] = [component_sizes[cid] for cid in component_ids]edge_records = []for eid, (i, j) in enumerate(edges, start=1): ci = frac_df.loc[i, ["center_x", "center_y", "center_z"]].values.astype(float) cj = frac_df.loc[j, ["center_x", "center_y", "center_z"]].values.astype(float) edge_records.append({ "edge_id": eid, "fracture_i": int(frac_df.loc[i, "label"]), "fracture_j": int(frac_df.loc[j, "label"]), "set_i": frac_df.loc[i, "set_id"], "set_j": frac_df.loc[j, "set_id"], "center_distance": float(np.linalg.norm(ci - cj)) })edge_df = pd.DataFrame(edge_records)frac_table_path = os.path.join(out_dir, "dfn_fracture_table.csv")edge_table_path = os.path.join(out_dir, "dfn_connection_edges.csv")frac_df.to_csv(frac_table_path, index=False, encoding="utf-8-sig")edge_df.to_csv(edge_table_path, index=False, encoding="utf-8-sig")# 输出总体统计largest_component_size = int(frac_df["component_size"].max())connected_cluster_count = int(len(np.unique(component_ids)))mean_degree = float(np.mean(degree))max_degree = int(np.max(degree))summary_records = []summary_records.append({ "item": "total_fractures", "value": n_frac})summary_records.append({ "item": "total_connections", "value": len(edges)})summary_records.append({ "item": "connected_cluster_count", "value": connected_cluster_count})summary_records.append({ "item": "largest_component_size", "value": largest_component_size})summary_records.append({ "item": "mean_degree", "value": mean_degree})summary_records.append({ "item": "max_degree", "value": max_degree})summary_records.append({ "item": "mean_trace_length", "value": float(frac_df["trace_length"].mean())})summary_records.append({ "item": "mean_nearest_same_set_spacing", "value": float(np.nanmean(frac_df["nearest_same_set_spacing"]))})for sid in frac_df["set_id"].unique(): sub = frac_df[frac_df["set_id"] == sid] summary_records.append({ "item": f"{sid}_count", "value": int(len(sub)) }) summary_records.append({ "item": f"{sid}_mean_dip", "value": float(sub["dip_deg"].mean()) }) summary_records.append({ "item": f"{sid}_mean_trace_length", "value": float(sub["trace_length"].mean()) }) summary_records.append({ "item": f"{sid}_mean_degree", "value": float(sub["degree"].mean()) })summary_df = pd.DataFrame(summary_records)summary_path = os.path.join(out_dir, "dfn_summary.csv")summary_df.to_csv(summary_path, index=False, encoding="utf-8-sig")#图1:三维 DFN 模型group_color = { js["set_id"]: js["color"] for js in joint_sets}fig = plt.figure(figsize=(9.0, 7.6))ax = fig.add_subplot(111, projection="3d")for idx, poly in enumerate(polygons): sid = frac_df.loc[idx, "set_id"] face_color = group_color[sid] patch = Poly3DCollection( [poly], facecolors=face_color, edgecolors="k", linewidths=0.25, alpha=0.38 ) ax.add_collection3d(patch)draw_cube(ax, L)ax.set_xlim(0, L)ax.set_ylim(0, L)ax.set_zlim(0, L)ax.set_xlabel("X / East", labelpad=8)ax.set_ylabel("Y / North", labelpad=8)ax.set_zlabel("Z / Up", labelpad=8)ax.set_title("3D discrete fracture network (DFN)", fontsize=16, pad=18)ax.view_init(elev=24, azim=-55)ax.set_box_aspect((1, 1, 1))legend_handles = []for js in joint_sets: h = plt.Line2D( [0], [0], marker="s", color="w", markerfacecolor=js["color"], markeredgecolor="k", markersize=10, label=js["name"] ) legend_handles.append(h)ax.legend( handles=legend_handles, loc="upper left", bbox_to_anchor=(0.02, 0.98), frameon=True, fontsize=9)txt = ( f"Fractures = {n_frac}\n" f"Connections = {len(edges)}\n" f"Largest cluster = {largest_component_size}\n" f"Mean degree = {mean_degree:.2f}")ax.text2D( 0.72, 0.04, txt, transform=ax.transAxes, fontsize=10, bbox=dict( boxstyle="round,pad=0.35", facecolor="white", edgecolor="#999999", alpha=0.90 ))plt.tight_layout()fig1_path = os.path.join(out_dir, "fig1_3d_dfn_model.png")plt.savefig(fig1_path, bbox_inches="tight", facecolor="white")plt.close(fig)# 图2:赤平投影图fig, ax = plt.subplots(figsize=(7.4, 7.4))draw_stereonet_grid(ax)# 极点密度背景xy = frac_df[["stereo_x", "stereo_y"]].values.Ttry: jitter = np.random.normal(0, 1e-4, size=xy.shape) kde = gaussian_kde(xy + jitter) grid_n = 180 xg = np.linspace(-1, 1, grid_n) yg = np.linspace(-1, 1, grid_n) Xg, Yg = np.meshgrid(xg, yg) pos = np.vstack([Xg.ravel(), Yg.ravel()]) Z = kde(pos).reshape(Xg.shape) Z[Xg ** 2 + Yg ** 2 > 1] = np.nan ax.contourf( Xg, Yg, Z, levels=10, cmap="Greys", alpha=0.28 )except Exception: passfor js in joint_sets: sub = frac_df[frac_df["set_id"] == js["set_id"]] ax.scatter( sub["stereo_x"], sub["stereo_y"], s=42, color=js["color"], edgecolors="white", linewidths=0.7, alpha=0.88, label=js["name"] )ax.set_title("Stereonet projection of fracture poles", fontsize=16, pad=12)ax.legend( loc="lower left", bbox_to_anchor=(0.02, 0.02), frameon=True, fontsize=9)plt.tight_layout()fig2_path = os.path.join(out_dir, "fig2_stereonet_poles.png")plt.savefig(fig2_path, bbox_inches="tight", facecolor="white")plt.close(fig)# 图3:节理连通性网络图fig, axes = plt.subplots(1, 2, figsize=(13.6, 6.0))# ---- 左图:空间连通网络 ----ax = axes[0]centers_xy = frac_df[["center_x", "center_y"]].valuesfor i, j in edges: xi, yi = centers_xy[i] xj, yj = centers_xy[j] ax.plot( [xi, xj], [yi, yj], color="0.55", linewidth=0.55, alpha=0.35, zorder=1 )for js in joint_sets: sub = frac_df[frac_df["set_id"] == js["set_id"]] node_size = 30 + 20 * sub["degree"].values ax.scatter( sub["center_x"], sub["center_y"], s=node_size, color=js["color"], edgecolors="black", linewidths=0.45, alpha=0.90, label=js["name"], zorder=3 )ax.set_xlim(0, L)ax.set_ylim(0, L)ax.set_aspect("equal")ax.set_xlabel("X / East")ax.set_ylabel("Y / North")ax.set_title("Spatial connectivity network", fontsize=14)ax.grid(alpha=0.18)ax.legend(loc="upper right", frameon=True, fontsize=9)net_txt = ( f"Nodes = {n_frac}\n" f"Edges = {len(edges)}\n" f"Mean degree = {mean_degree:.2f}\n" f"Max degree = {max_degree}")ax.text( 0.03, 0.03, net_txt, transform=ax.transAxes, ha="left", va="bottom", fontsize=9.5, bbox=dict( boxstyle="round,pad=0.35", facecolor="white", edgecolor="#999999", alpha=0.92 ))# ---- 右图:邻接矩阵 ----ax = axes[1]set_order = []for js in joint_sets: set_order.extend(frac_df.index[frac_df["set_id"] == js["set_id"]].tolist())adj = np.zeros((n_frac, n_frac), dtype=float)for i, j in edges: adj[i, j] = 1 adj[j, i] = 1adj_sorted = adj[np.ix_(set_order, set_order)]ax.imshow( adj_sorted, cmap="Greys", interpolation="nearest", vmin=0, vmax=1)ax.set_title("Fracture adjacency matrix", fontsize=14)ax.set_xlabel("Fracture index sorted by joint set")ax.set_ylabel("Fracture index sorted by joint set")# 节理组分隔线start = 0for js in joint_sets[:-1]: start += js["n"] ax.axhline(start - 0.5, color="red", linewidth=0.8, alpha=0.8) ax.axvline(start - 0.5, color="red", linewidth=0.8, alpha=0.8)plt.tight_layout()fig3_path = os.path.join(out_dir, "fig3_fracture_connectivity_network.png")plt.savefig(fig3_path, bbox_inches="tight", facecolor="white")plt.close(fig)