OSCR

A manufacturability-informed topology framework for AI-guided design of fibrous network materials.

Code ↔ Paper

21 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 21 matches
  1. [1] § Methods › Network generation and topological control ↔ Quick_Start.ipynb, lines 1–56 · score 0.85 · dimensional encoding sequence, rotational symmetry, planar displacement, topological control points, square, Eulerian
  2. [2] § Methods › FE modeling and automated simulation analysis ↔ simulation_setup_compress.py, lines 251–331 · score 0.85 · Mooney Rivlin, tabular amplitude, large deformation, Poisson, dissipation, energy
  3. [3] § Methods › FE modeling and automated simulation analysis ↔ simulation_setup_shear.py, lines 236–295 · score 0.84 · Mooney Rivlin, tabular amplitude, large deformation, Poisson, dissipation, energy
  4. [4] § Methods › RL-based optimization framework ↔ Quick_Start.ipynb, lines 1712–1781 · score 0.81 · replay buffer, discount factor, target entropy, critic, soft, coefficient
  5. [5] § Methods › RL-based optimization framework ↔ RL-2.py, lines 502–574 · score 0.79 · replay buffer, target entropy, target networks, critic, actor, soft
  6. [6] § Methods › RL-based optimization framework ↔ Quick_Start.ipynb, lines 1375–1428 · score 0.78 · strict mass, Loss Weight Mode, Fixed Weight Mode, reward function, RL
  7. [7] § Methods › Network generation and topological control ↔ Quick_Start.ipynb, lines 1–56 · score 0.77 · anchor point regulation, regular tiling, single fiber, REFINe framework, Eulerian, graph
  8. [8] § Methods › GNN prediction model ↔ Quick_Start.ipynb, lines 573–635 · score 0.72 · twelve, Maxwell, Pearson, anisotropy, rigidity, collinear
  9. [9] § Results › Reinforcement learning for inverse design of fibrous networks ↔ Quick_Start.ipynb, lines 1375–1428 · score 0.72 · Loss Weight Mode, Fixed Weight Mode, switching, variants, instantaneous, mass
  10. [10] § Results › Physics-inspired graph learning for topology-mechanics prediction ↔ Quick_Start.ipynb, lines 573–635 · score 0.69 · topological features, axial, Gini, variation, fractal, fibrous network
  11. [11] § Methods › Fabrication and application of three-dimensional regular fibrous network architectures ↔ Quick_Start.ipynb, lines 1952–1984 · score 0.68 · surface mapping technique, regular fibrous network, export, fabricated, architectures, curved
  12. [12] § Methods › FE modeling and automated simulation analysis ↔ Quick_Start.ipynb, lines 411–464 · score 0.63 · removed redundant, sketch generation, collinearity, simplification, contours, loop
  13. [13] § Results › Automated multiscale simulation linking topology to mechanics ↔ color-mapping.ipynb, lines 317–370 · score 0.60 · 0–37.22, 0–76.77, stress ranges, Mises, MPa
  14. [14] § Results › Physics-inspired graph learning for topology-mechanics prediction ↔ color-mapping.ipynb, lines 317–370 · score 0.60 · 0–29.18, 0–77.84, stress ranges, Mises, MPa
  15. [15] § Methods › Fabrication and application of three-dimensional regular fibrous network architectures ↔ Quick_Start.ipynb, lines 1952–1984 · score 0.60 · QuadriFlow, regular fibrous network, quadrilateral, fabricated, surface, encoded
  16. [16] § Methods › RL-based optimization framework ↔ RL-2.py, lines 301–431 · score 0.59 · parameter vector, edge length, deviation, distance, reward, RL
  17. [17] § Methods › FE modeling and automated simulation analysis ↔ simulation_setup.py, lines 42–175 · score 0.57 · rigid body, centroids, height, coupling, surface, displacement
  18. [18] § Methods › FE modeling and automated simulation analysis ↔ simulation_setup_compress.py, lines 55–188 · score 0.57 · rigid body, centroids, height, coupling, surface, displacement
  19. [19] § Results › Automated multiscale simulation linking topology to mechanics ↔ Quick_Start.ipynb, lines 411–464 · score 0.56 · Shoelace area filter, sketch, verification, simplification, domain, redundant
  20. [20] § Methods › GNN prediction model ↔ Quick_Start.ipynb, lines 59–74 · score 0.51 · Node attributes, regular fibrous network, undirected, junctions, deformation, graph
  21. [21] § Methods › FE modeling and automated simulation analysis ↔ geometry_mesh.py, lines 175–324 · score 0.50 · deformable bodies, extrude, thickness, sketch, meshing, modeling

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Jupyter notebook · 2,287 lines · 98 KB · MIT · 12 matches

  1. # %% [markdown]
  2. # **Quick_Start.ipynb is a simplified, fast-execution script for the overall framework.**
  3. # %% [markdown]
  4. # Use "conda env create -f environment.yml" at first.
  5. # %%
  6. """
  7. Quick_Start.ipynb — Cell 1: Eulerian-compliant Square Graph Generator
  8. =====================================================================
  9. This cell implements the foundational topology-generation routine of the REFINe
  10. framework, corresponding to the *Network generation and topological control*
  11. stage described in Section 4.1 of the manuscript.
  12. The generator follows a five-stage graph-based construction protocol:
  13. (i) Eulerian base-unit generation under the rotational-symmetry constraint
  14. d(v) ≡ 0 (mod 2), guaranteeing single-fiber traversability.
  15. (ii) Anchor-point regulation via a 10-dimensional encoding sequence
  16. (dx_i, dy_i), i = 1..5, that parametrises the planar displacement of
  17. control points along one canonical edge; the remaining three edges
  18. are obtained by 90° rotational replication.
  19. (iii) Regular tiling: the deformed unit cell is replicated into an
  20. (t × t) periodic lattice and rescaled to the standardised
  21. (1 × 1) design domain (later mapped to the 10 cm × 10 cm physical
  22. domain during FEA).
  23. (iv) Welding (anchor-point insertion at line-segment intersections),
  24. implemented through cross-product-based geometric predicates on
  25. edge pairs treated as 2-D LineString primitives.
  26. (v) Self-loop pruning to remove degenerate zero-length edges that may
  27. arise from collinear deformations near the unit-cell corners.
  28. Outputs
  29. -------
  30. Each generated configuration is persisted as:
  31. * Graph_Data/<sample_id>.json — the raw deformed lattice
  32. * Weld_Graph_Data/<sample_id>.json — the topology after welding
  33. * Image_Data/<sample_id>.png — a rasterised preview
  34. Notes
  35. -----
  36. The 10-D encoding sequence DX_DY_SEQUENCES exported at the end of this cell
  37. is reused downstream by the SAC inverse-design module (Section 4.5) as the
  38. initial point of the continuous action space.
  39. """
  40. import os
  41. import copy
  42. import itertools
  43. import json
  44. import random
  45. import numpy as np
  46. from tqdm import tqdm
  47. import networkx as nx
  48. import matplotlib.pyplot as plt
  49. from shapely.geometry import LineString, Point
  50. class SquareGraphGenerator:
  51. """
  52. Topology-preserving generator for regular fibrous network base units.
  53. The class encapsulates the Eulerian base-unit generation, isomorphic
  54. deformation, regular tiling, and welding stages of TOPNet. All operations
  55. are performed on a NetworkX undirected graph whose node attribute ``pos``
  56. stores the planar (x, y) coordinate of each fibre junction.
  57. Parameters
  58. ----------
  59. base_path : str, optional
  60. Root directory under which the three output subfolders
  61. (``Graph_Data``, ``Image_Data``, ``Weld_Graph_Data``) will be created.
  62. Defaults to the current working directory.
  63. """
  64. def __init__(self, base_path=os.getcwd()):
  65. self.base_path = base_path
  66. self.dataset_path = os.path.join(self.base_path, 'Dataset')
  67. self.graph_data_path = os.path.join(self.dataset_path, 'Graph_Data')
  68. self.image_data_path = os.path.join(self.dataset_path, 'Image_Data')
  69. self.weld_graph_data_path = os.path.join(self.dataset_path, 'Weld_Graph_Data')
  70. os.makedirs(self.graph_data_path, exist_ok=True)
  71. os.makedirs(self.image_data_path, exist_ok=True)
  72. os.makedirs(self.weld_graph_data_path, exist_ok=True)
  73. # Container for the 10-D encoding sequences emitted by this run;
  74. # reused as warm-start states by the downstream RL agent.
  75. self.dx_dy_sequences = []
  76. def generate_square_graph(self, side_length, num_points_per_side):
  77. """
  78. Construct the canonical (undeformed) Eulerian base unit.
  79. A closed square contour is discretised by ``num_points_per_side``
  80. equally spaced control points along each of the four edges. The
  81. resulting graph satisfies d(v) = 2 ∀ v on the perimeter, which is a
  82. sufficient condition for Eulerian traversability of the welded
  83. topology generated downstream.
  84. Parameters
  85. ----------
  86. side_length : float
  87. Edge length of the square in design-domain units.
  88. num_points_per_side : int
  89. Number of *interior* control points per edge (typically 5,
  90. matching the 10-D encoding scheme).
  91. Returns
  92. -------
  93. networkx.Graph
  94. Graph with corner nodes ``A, B, C, D`` and interior nodes named
  95. ``<edge><index>`` (e.g. ``AB1``).
  96. """
  97. G = nx.Graph()
  98. # Four corners of the canonical unit cell.
  99. vertices = {'A': (0, 0), 'B': (side_length, 0),
  100. 'C': (side_length, side_length), 'D': (0, side_length)}
  101. for vertex, position in vertices.items():
  102. G.add_node(vertex, pos=position)
  103. # Insert k interior control points per edge with linear interpolation.
  104. edges = [('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'A')]
  105. for start, end in edges:
  106. G.add_edge(start, end)
  107. start_pos = np.array(vertices[start])
  108. end_pos = np.array(vertices[end])
  109. for j in range(1, num_points_per_side + 1):
  110. t = j / (num_points_per_side + 1)
  111. point_pos = (1 - t) * start_pos + t * end_pos
  112. point_name = f"{start}{end}{j}"
  113. G.add_node(point_name, pos=tuple(point_pos))
  114. if j == 1:
  115. G.add_edge(start, point_name)
  116. if j == num_points_per_side:
  117. G.add_edge(point_name, end)
  118. if j > 1:
  119. prev_point_name = f"{start}{end}{j-1}"
  120. G.add_edge(prev_point_name, point_name)
  121. return G
  122. def move_AB(self, G, num, dx, dy, side_length):
  123. """
  124. Apply an anchor-point displacement with C4 rotational replication.
  125. Given a single planar perturbation (dx, dy) prescribed on edge AB,
  126. this routine propagates the perturbation to edges BC, CD, DA via the
  127. rotation matrix R(π/2), thereby preserving rotational symmetry and
  128. maintaining the even-degree (Eulerian) condition globally.
  129. The perturbation is applied multiplicatively in units of
  130. ``side_length`` so that the encoding sequence is scale-invariant.
  131. After perturbation, the four corner edges are rebuilt as a closed
  132. cycle, which is required because the deformed control points may no
  133. longer be collinear with their original edges.
  134. Parameters
  135. ----------
  136. G : networkx.Graph
  137. Output of :meth:`generate_square_graph`.
  138. num : int
  139. Index (1..k) of the control point being perturbed.
  140. dx, dy : float
  141. Normalised in-plane displacement components in [-0.5, 0.5].
  142. side_length : float
  143. Same scalar used in :meth:`generate_square_graph`.
  144. Returns
  145. -------
  146. networkx.Graph
  147. A new graph instance with displaced control points and rebuilt
  148. corner-cycle connectivity.
  149. """
  150. new_G = nx.Graph()
  151. new_G.add_nodes_from(G.nodes(data=True))
  152. dx, dy = dx * side_length, dy * side_length
  153. # 90° rotational propagation: (dx,dy) -> (-dy,dx) -> (-dx,-dy) -> (dy,-dx)
  154. positions = {
  155. f'AB{num}': (dx, dy),
  156. f'BC{num}': (-dy, dx),
  157. f'CD{num}': (-dx, -dy),
  158. f'DA{num}': (dy, -dx)
  159. }
  160. for node, (dx_offset, dy_offset) in positions.items():
  161. if node in new_G.nodes:
  162. current_pos = new_G.nodes[node]['pos']
  163. new_G.nodes[node]['pos'] = (current_pos[0] + dx_offset,
  164. current_pos[1] + dy_offset)
  165. # Re-establish the perimeter cycle on the lexicographically sorted
  166. # node list. This is intentionally simpler than reconstructing the
  167. # original A-AB1-...-B-BC1-...-C-...-A traversal because the welding
  168. # stage will subsequently re-discretise every edge at intersection
  169. # points, rendering the intermediate ordering immaterial.
  170. new_G.remove_edges_from(list(new_G.edges))
  171. node_list = sorted(new_G.nodes)
  172. for i in range(len(node_list)):
  173. new_G.add_edge(node_list[i], node_list[(i + 1) % len(node_list)])
  174. return new_G
  175. def scale_and_tile_graph(self, new_G, tiling_number, scale_size_num=1,
  176. side_length=10):
  177. """
  178. Replicate the deformed base unit into a (t × t) regular tiling.
  179. Each tile copy is rigidly translated by integer multiples of
  180. ``side_length`` and the global lattice is then uniformly rescaled by
  181. ``scale_size_num / (side_length * tiling_number)`` so that the entire
  182. construct fits inside the unit square [0, scale_size_num]^2. This
  183. normalisation is what allows the FEA pipeline (Section 4.2) to treat
  184. every sample on a common (10 × 10) physical domain regardless of
  185. ``tiling_number``.
  186. """
  187. scaled_tiled_G = nx.Graph()
  188. original_positions = nx.get_node_attributes(new_G, 'pos')
  189. scale_factor = scale_size_num / (side_length * tiling_number)
  190. for i in range(tiling_number):
  191. for j in range(tiling_number):
  192. for node, position in original_positions.items():
  193. new_x = (position[0] + i * side_length) * scale_factor
  194. new_y = (position[1] + j * side_length) * scale_factor
  195. scaled_tiled_G.add_node(f"{node}_{i}_{j}",
  196. pos=(new_x, new_y))
  197. for i in range(tiling_number):
  198. for j in range(tiling_number):
  199. for edge in new_G.edges():
  200. node1, node2 = edge
  201. scaled_tiled_G.add_edge(f"{node1}_{i}_{j}",
  202. f"{node2}_{i}_{j}")
  203. return scaled_tiled_G
  204. def save_graph_to_json(self, G, original_filename,
  205. directory='Graph_Data', suffix="_modified"):
  206. """Serialise a graph to disk in NetworkX node-link JSON format."""
  207. base_name, ext = os.path.splitext(original_filename)
  208. new_filename = f"{base_name}{suffix}{ext}"
  209. new_path = os.path.join(self.dataset_path, directory, new_filename)
  210. graph_data = nx.node_link_data(G)
  211. with open(new_path, 'w', encoding='utf-8') as f:
  212. json.dump(graph_data, f, ensure_ascii=False, indent=4)
  213. def visualize_and_save_graph(self, G, save_path):
  214. """Render a binary preview PNG used downstream by the contour and
  215. feature-extraction modules."""
  216. pos = nx.get_node_attributes(G, 'pos')
  217. x_values, y_values = zip(*pos.values())
  218. x_min, x_max = min(x_values), max(x_values)
  219. y_min, y_max = min(y_values), max(y_values)
  220. x_range, y_range = x_max - x_min, y_max - y_min
  221. plt.figure(figsize=(6, 6))
  222. nx.draw_networkx_edges(G, pos, width=3)
  223. ax = plt.gca()
  224. for spine in ax.spines.values():
  225. spine.set_visible(False)
  226. ax.set_xticks([])
  227. ax.set_yticks([])
  228. plt.xlim(x_min - 0.05 * x_range, x_max + 0.05 * x_range)
  229. plt.ylim(y_min - 0.05 * y_range, y_max + 0.05 * y_range)
  230. plt.savefig(save_path, bbox_inches='tight')
  231. plt.close()
  232. def generate_batch(self, num_images=10, tiling_number=6, side_length=10,
  233. num_points_per_side=5, scale_size_num=1):
  234. """
  235. Sample a batch of regular fibrous networks under uniform random
  236. encoding and persist their three downstream artefacts (raw graph,
  237. welded graph, preview image).
  238. For each sample, five (dx, dy) pairs are drawn independently from
  239. U(-0.5, 0.5), assembled into a 10-D encoding vector, and then passed
  240. sequentially through generate_square_graph → move_AB(×5) →
  241. scale_and_tile_graph. The encoding vector is appended to
  242. ``self.dx_dy_sequences`` for downstream consumption by the RL agent.
  243. """
  244. for i in tqdm(range(num_images), desc="Generating Images"):
  245. dx_dy_values = [(round(random.uniform(-0.5, 0.5), 2),
  246. round(random.uniform(-0.5, 0.5), 2))
  247. for _ in range(5)]
  248. seq = np.array([v for pair in dx_dy_values for v in pair])
  249. self.dx_dy_sequences.append(seq)
  250. tiling_number_int = int(tiling_number)
  251. save_name = (str(tiling_number_int) + '_' +
  252. '_'.join([f'{dx}_{dy}' for dx, dy in dx_dy_values]))
  253. save_path_img = os.path.join(self.image_data_path,
  254. save_name + '.png')
  255. G = self.generate_square_graph(side_length, num_points_per_side)
  256. for idx, (dx, dy) in enumerate(dx_dy_values, start=1):
  257. G = self.move_AB(G, idx, dx, dy, side_length)
  258. scaled_tiled_G = self.scale_and_tile_graph(
  259. G, tiling_number,
  260. scale_size_num=scale_size_num,
  261. side_length=side_length
  262. )
  263. original_filename = f"{save_name}.json"
  264. self.save_graph_to_json(scaled_tiled_G, original_filename,
  265. directory='Graph_Data', suffix="")
  266. self.visualize_and_save_graph(scaled_tiled_G, save_path_img)
  267. def intersection_graph(self, original_graph):
  268. """
  269. Welding stage: insert anchor nodes at every pairwise edge intersection.
  270. Each edge is interpreted as a 2-D LineString and all C(|E|, 2) pairs
  271. are tested for intersection. When a proper Point intersection is
  272. detected, a new node is inserted at that location and both incident
  273. edges are split accordingly. This is the geometric realisation of the
  274. anchor-point regulation step in the manuscript and is what bridges
  275. the abstract topology to the manufacturable single-fibre path.
  276. The complexity is O(|E|²) and dominates the cost of the pipeline for
  277. dense tilings; an interval-tree acceleration is left as future work.
  278. """
  279. G = copy.deepcopy(original_graph)
  280. edges = list(G.edges(data=True))
  281. new_nodes = {}
  282. new_edges = []
  283. intersections = {}
  284. for (u1, v1, data1), (u2, v2, data2) in itertools.combinations(edges, 2):
  285. pos_u1 = G.nodes[u1]['pos']; pos_v1 = G.nodes[v1]['pos']
  286. line1 = LineString([pos_u1, pos_v1])
  287. pos_u2 = G.nodes[u2]['pos']; pos_v2 = G.nodes[v2]['pos']
  288. line2 = LineString([pos_u2, pos_v2])
  289. if line1.intersects(line2):
  290. intersection = line1.intersection(line2)
  291. if "Point" == intersection.geom_type:
  292. ix, iy = intersection.x, intersection.y
  293. intersection_node = f'IX_{ix}_{iy}'
  294. if intersection_node not in G.nodes:
  295. new_nodes[intersection_node] = {'pos': (ix, iy)}
  296. intersections.setdefault((u1, v1), []).append((ix, iy))
  297. intersections.setdefault((u2, v2), []).append((ix, iy))
  298. for node, attrs in new_nodes.items():
  299. G.add_node(node, pos=attrs['pos'])
  300. # Split each original edge at its intersection points, ordered along
  301. # the edge by linear projection so that the resulting sub-edges form
  302. # a valid 1-D simplicial chain.
  303. for (u, v, data) in tqdm(edges, total=len(edges)):
  304. points = intersections.get((u, v), [])
  305. if not points:
  306. new_edges.append((u, v))
  307. continue
  308. pos_u = G.nodes[u]['pos']; pos_v = G.nodes[v]['pos']
  309. line = LineString([pos_u, pos_v])
  310. sorted_points = sorted(points, key=lambda p: line.project(Point(p)))
  311. prev_node = u
  312. for point in sorted_points:
  313. ix, iy = point
  314. intersection_node = f'IX_{ix}_{iy}'
  315. new_edges.append((prev_node, intersection_node))
  316. prev_node = intersection_node
  317. new_edges.append((prev_node, v))
  318. G.remove_edges_from(edges)
  319. G.add_edges_from(new_edges)
  320. return G
  321. def Remove_self_join(self, original_graph):
  322. """Prune zero-length self-coincident edges that may arise when two
  323. adjacent control points are perturbed onto the same location."""
  324. G = copy.deepcopy(original_graph)
  325. edges = list(G.edges(data=True))
  326. new_edges = []
  327. for (u, v, data) in edges:
  328. if G.nodes[u]['pos'] != G.nodes[v]['pos']:
  329. new_edges.append((u, v))
  330. G.remove_edges_from(edges)
  331. G.add_edges_from(new_edges)
  332. return G
  333. def process_graph_data(self):
  334. """Apply welding + self-loop removal to every JSON sample under
  335. ``Graph_Data`` and persist the result under ``Weld_Graph_Data``."""
  336. for filename in tqdm(os.listdir(self.graph_data_path),
  337. desc="Processing JSON files"):
  338. if filename.endswith('.json'):
  339. input_path = os.path.join(self.graph_data_path, filename)
  340. with open(input_path, 'r', encoding='utf-8') as f:
  341. graph_data = json.load(f)
  342. G_original = nx.node_link_graph(graph_data)
  343. G_intersection = self.intersection_graph(G_original)
  344. G_intersection = self.Remove_self_join(G_intersection)
  345. self.save_graph_to_json_custom(
  346. G_intersection, filename, directory='Weld_Graph_Data')
  347. def save_graph_to_json_custom(self, G, filename, directory='Graph_Data'):
  348. """Internal helper used by :meth:`process_graph_data`."""
  349. new_path = os.path.join(self.dataset_path, directory, filename)
  350. graph_data = nx.node_link_data(G)
  351. with open(new_path, 'w', encoding='utf-8') as f:
  352. json.dump(graph_data, f, ensure_ascii=False, indent=4)
  353. # ---------------------------------------------------------------------------
  354. # Driver: emit one sample for each tiling level in the prescribed range and
  355. # expose the resulting paths and encoding sequences as module-level globals
  356. # for consumption by the subsequent cells.
  357. # ---------------------------------------------------------------------------
  358. generator = SquareGraphGenerator()
  359. for tiling_number in range(3, 4):
  360. generator.generate_batch(num_images=1, tiling_number=tiling_number)
  361. generator.process_graph_data()
  362. IMAGE_DATA_PATH = generator.image_data_path
  363. GRAPH_DATA_PATH = generator.graph_data_path
  364. WELD_GRAPH_DATA_PATH = generator.weld_graph_data_path
  365. DATASET_PATH = generator.dataset_path
  366. BASE_PATH = generator.base_path
  367. DX_DY_SEQUENCES = generator.dx_dy_sequences
  368. print("DX_DY_SEQUENCES sample:", DX_DY_SEQUENCES[0])
  369. # %%
  370. """
  371. Quick_Start.ipynb — Cell 2: Raster-to-vector contour extraction
  372. ================================================================
  373. This cell implements the *2-D sketch generation* component of the FEA
  374. pre-processing pipeline (Section 4.2). The previously rasterised network
  375. previews are reverse-mapped into vector contours that can be ingested by
  376. Abaqus 2024 as native sketch primitives.
  377. Pipeline
  378. --------
  379. (i) Inverse binary thresholding (Otsu-equivalent at 127) to isolate
  380. fibre topology against the white background.
  381. (ii) Hierarchical contour extraction with cv2.RETR_TREE /
  382. CHAIN_APPROX_NONE, preserving both the outer boundary and the
  383. internal pore loops required for downstream solid extrusion.
  384. (iii) Douglas-Peucker polygonal simplification (cv2.approxPolyDP) with
  385. curvature-adaptive epsilon = epsilon_factor * arclength, removing
  386. redundant collinear vertices while preserving structural fidelity.
  387. (iv) Affine normalisation onto the canonical (10 × 10) cm physical
  388. domain — identical to the FEA reference frame defined in §4.2 —
  389. via uniform scale factor 10 / max(W, H) and centred translation.
  390. (v) Per-loop closure enforcement: any contour whose first and last
  391. vertex differ is automatically closed, guaranteeing manifold
  392. 1-cycles for the downstream Shoelace area filter.
  393. Outputs
  394. -------
  395. Contour_Output/<sample_id>.csv — three-column CSV
  396. (contour_id, x_cm, y_cm)
  397. Contour_Output/Verification_Images/... — overlay PNGs for visual QA
  398. """
  399. import os
  400. import cv2
  401. import csv
  402. import numpy as np
  403. from tqdm import tqdm
  404. import matplotlib
  405. if os.environ.get("DISPLAY", "") == "":
  406. matplotlib.use("Agg")
  407. import matplotlib.pyplot as plt
  408. CONTOUR_OUTPUT_PATH = os.path.join(BASE_PATH, "Contour_Output")
  409. VERIFICATION_PATH = os.path.join(CONTOUR_OUTPUT_PATH, "Verification_Images")
  410. EPSILON_FACTOR = 0.0005 # Polyline simplification tolerance, in
  411. # fractions of the local arclength. Lower
  412. # values preserve more vertices.
  413. VERIFY = True
  414. os.makedirs(CONTOUR_OUTPUT_PATH, exist_ok=True)
  415. if VERIFY:
  416. os.makedirs(VERIFICATION_PATH, exist_ok=True)
  417. def process_image(image_path, output_folder, verify, verification_folder,
  418. epsilon_factor=0.0005):
  419. """
  420. Convert a single rasterised network preview into normalised vector
  421. contours and persist the result as a flat CSV.
  422. Parameters
  423. ----------
  424. image_path : str
  425. Path to a grayscale binary preview produced by Cell 1.
  426. output_folder : str
  427. Destination directory for the per-sample CSV.
  428. verify : bool
  429. If True, emit a verification overlay PNG into ``verification_folder``.
  430. epsilon_factor : float, default 0.0005
  431. Multiplicative factor applied to each contour's arclength to derive
  432. the Douglas-Peucker tolerance. The default has been calibrated to
  433. retain Eulerian junctions at the operating canvas resolution.
  434. """
  435. img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
  436. if img is None:
  437. return
  438. height, width = img.shape
  439. # Inverse threshold: fibre pixels become foreground (255) so that
  440. # cv2.findContours treats them as connected components.
  441. ret, thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
  442. contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE,
  443. cv2.CHAIN_APPROX_NONE)
  444. if not contours:
  445. return
  446. # Compute the global affine transform that maps the raster bounding box
  447. # onto the canonical (10 × 10) cm physical domain used throughout the FEA
  448. # pipeline. The Y-axis is flipped (height - y) to convert image-row order
  449. # into Cartesian convention.
  450. all_points = np.vstack([contour for contour in contours])
  451. x_min, y_min = all_points.min(axis=0)[0]
  452. x_max, y_max = all_points.max(axis=0)[0]
  453. sample_width = x_max - x_min
  454. sample_height = y_max - y_min
  455. scale_factor = 10.0 / max(sample_width, sample_height)
  456. x_offset = (10 - sample_width * scale_factor) / 2
  457. y_offset = (10 - sample_height * scale_factor) / 2
  458. csv_rows = []
  459. if verify:
  460. plt.figure(dpi=300, figsize=(6, 6))
  461. for cid, contour in enumerate(contours):
  462. # Curvature-adaptive Douglas-Peucker simplification.
  463. epsilon = epsilon_factor * cv2.arcLength(contour, True)
  464. approx = cv2.approxPolyDP(contour, epsilon, True)
  465. contour_points = []
  466. for pt in approx:
  467. x, y = pt[0]
  468. x_cm = (x - x_min) * scale_factor + x_offset
  469. y_cm = ((height - y) - y_min) * scale_factor + y_offset
  470. contour_points.append([x_cm, y_cm])
  471. csv_rows.append([cid, x_cm, y_cm])
  472. # Enforce explicit closure of every loop, so the downstream
  473. # Shoelace area filter operates on well-defined 1-cycles.
  474. if len(contour_points) > 0:
  475. first_point = contour_points[0]
  476. last_point = contour_points[-1]
  477. if first_point[0] != last_point[0] or first_point[1] != last_point[1]:
  478. contour_points.append(first_point)
  479. if verify:
  480. xs = [p[0] for p in contour_points]
  481. ys = [p[1] for p in contour_points]
  482. plt.plot(xs, ys, 'b-', linewidth=1)
  483. base_name = os.path.splitext(os.path.basename(image_path))[0]
  484. output_csv = os.path.join(output_folder, base_name + ".csv")
  485. with open(output_csv, "w", newline="") as f:
  486. writer = csv.writer(f)
  487. writer.writerow(["contour_id", "x_cm", "y_cm"])
  488. for row in csv_rows:
  489. writer.writerow(row)
  490. if verify:
  491. plt.xlim(0, 10); plt.ylim(0, 10)
  492. plt.gca().set_aspect("equal", adjustable="box")
  493. plt.axis('off')
  494. ver_path = os.path.join(verification_folder, base_name + ".png")
  495. plt.savefig(ver_path, bbox_inches="tight", pad_inches=0)
  496. plt.close()
  497. valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"]
  498. image_files = [
  499. os.path.join(IMAGE_DATA_PATH, f)
  500. for f in os.listdir(IMAGE_DATA_PATH)
  501. if os.path.splitext(f)[1].lower() in valid_ext
  502. ]
  503. for image_path in tqdm(image_files, desc="Processing images"):
  504. process_image(
  505. image_path,
  506. CONTOUR_OUTPUT_PATH,
  507. VERIFY,
  508. VERIFICATION_PATH,
  509. epsilon_factor=EPSILON_FACTOR
  510. )
  511. # %%
  512. """
  513. Quick_Start.ipynb — Cell 3: Multi-modal topological feature extractor
  514. ======================================================================
  515. This cell exposes :class:`GraphFeatureExtractor`, the descriptor-engineering
  516. backbone underpinning the surrogate predictor of Section 4.4. For every
  517. welded fibrous network it emits a fixed-length feature vector that fuses
  518. *graph-theoretic*, *spectral*, *fractal*, *combinatorial*, and *image-domain
  519. contact* signals into a single representation suitable for tree-based
  520. ensemble learning.
  521. Feature taxonomy (90+ descriptors)
  522. ---------------------------------
  523. 1. Basic size : node/edge counts, total/mean fibre length,
  524. length coefficient of variation.
  525. 2. Degree statistics : even-parity counts (deg=2, deg=4) and the
  526. Shannon entropy of the degree distribution.
  527. 3. Orientation statistics : 18-bin angular entropy and the planar
  528. anisotropy index derived from the second-
  529. order direction tensor Q = (1/|E|) Σ uuᵀ.
  530. 4. Spatial moments : radius of gyration and degree-weighted
  531. first moment.
  532. 5. Path / connectivity : average clustering, Fiedler value
  533. (λ₂ of the graph Laplacian), λ_max via
  534. sparse Lanczos, spectral-gap ratio,
  535. and the giant-component ASPL.
  536. 6. Boundary & fractal : perimeter-edge fraction and box-counting
  537. dimension over k = 1..6 dyadic scales.
  538. 7. Redundancy & rigidity : Maxwell rigidity index (|E| − 2|V| + 3),
  539. cyclomatic redundancy, and k-core depth.
  540. 8. Cycle features : triangle and 4-cycle counts via cycle
  541. basis enumeration.
  542. 9. Vertical shortestness : top-to-bottom Dijkstra distance using
  543. |Δy| edge weights — a proxy for axial
  544. load-path tortuosity under uniaxial pull.
  545. 10. Mesh holes (image-domain) : connected-component statistics of the
  546. negative-space distribution.
  547. 11. Edge betweenness : maximum value and Gini concentration.
  548. 12. Pore features : top-K convexity, circularity, area
  549. moments, and centre/edge spatial split.
  550. 13. Contact features : fibre-overlap pixel statistics derived
  551. from anti-aliased rasterisation, used as
  552. a direct surrogate for the anchor-point
  553. regulation density discussed in §4.1.
  554. After downstream Pearson-based decorrelation (|r| > 0.8), twelve canonical
  555. non-collinear descriptors are retained as model inputs (see §4.4).
  556. """
  557. from pathlib import Path
  558. from collections import defaultdict
  559. import json
  560. import math
  561. import warnings
  562. import numpy as np
  563. import pandas as pd
  564. import networkx as nx
  565. from scipy.stats import entropy, linregress, skew as sp_skew, kurtosis as sp_kurtosis
  566. from scipy.sparse.linalg import eigsh
  567. import cv2
  568. warnings.filterwarnings("ignore", category=RuntimeWarning)
  569. class GraphFeatureExtractor:
  570. """
  571. Stateless descriptor engine for welded regular fibrous networks.
  572. Two ingestion modes are supported: :meth:`extract_from_path` for
  573. NetworkX node-link JSON files (Cell 1 output) and
  574. :meth:`extract_from_graph` for in-memory NetworkX objects, the latter
  575. being the preferred entry point inside the SAC inner loop (Cell 5)
  576. where avoiding round-trip serialisation is critical for throughput.
  577. Parameters
  578. ----------
  579. canvas_size : int, default 1024
  580. Side length, in pixels, of the rasterisation canvas used for the
  581. image-domain pore and contact descriptors.
  582. thick : int, default 9
  583. Anti-aliased stroke width for the simulated fibre rendering. The
  584. default has been calibrated to reproduce the physical fibre-to-cell
  585. size ratio of the printed TPU specimens (§4.3).
  586. edge_margin : float, default 0.12
  587. Relative margin (in canvas units) defining the boundary band that
  588. separates "centre" from "edge" pores in the spatial-split feature.
  589. top_k : int, default 3
  590. Number of largest pores retained for shape-quality descriptors
  591. (convexity, circularity).
  592. area_thresh : float, default 0.005
  593. Lower bound (relative to canvas area) above which a pore is counted
  594. as "structurally meaningful".
  595. connectivity : int, default 8
  596. Pixel adjacency for the connected-component analysis of overlap
  597. regions (4 or 8).
  598. """
  599. # ------------------------------------------------------------------
  600. # Canonical feature ordering. Downstream code (LoadPredictor in Cell 4)
  601. # relies on this list both for column selection and for the dimension
  602. # check against the trained tree-ensemble metadata.
  603. # ------------------------------------------------------------------
  604. FEATURE_COLS = [
  605. "n_node", "n_edge", "total_length", "mean_edge_len", "len_cv",
  606. "deg2_count", "deg4_count", "degree_entropy",
  607. "orient_entropy", "anisotropy",
  608. "radius_gyration", "moment_total",
  609. "clustering_coef", "fiedler_value", "lambda_max", "spectral_gap_ratio", "aspl_giant",
  610. "boundary_frac", "fractal_dim_box",
  611. "rigidity_index", "redundancy_ratio", "max_k_core", "kcore_frac",
  612. "triangle_count", "triangle_ratio", "quad_count", "quad_ratio",
  613. "avg_shortest_dy", "straightness",
  614. "mesh_median_area", "mesh_cv_area", "mesh_max_area_ratio",
  615. "edge_betweenness_max", "edge_betweenness_gini",
  616. "largest_pore_ratio", "top_area_sum_ratio",
  617. "top_convexity_min", "top_convexity_mean", "top_circularity_min",
  618. "big_pore_count", "total_pore_count",
  619. "total_pore_ratio", "center_pore_ratio", "edge_pore_ratio",
  620. "pore_area_cv", "pore_area_skew", "pore_area_kurtosis",
  621. "pore_area_max_over_mean", "pore_large_area_frac", "pore_count_large_frac",
  622. "pore_density", "pore_spatial_cv",
  623. "contact_thick", "contact_canvas_size", "contact_nodes", "contact_edges",
  624. "contact_edge_pixel_union_count", "contact_edge_pixel_sum",
  625. "contact_raw_overlap_pixel_count", "contact_overlap_pixel_count",
  626. "contact_overlap_pair_count", "contact_overlap_pairs_per_edge",
  627. "contact_edges_with_contact_count", "contact_edges_with_contact_ratio",
  628. "contact_overlap_pixel_ratio_union", "contact_raw_overlap_pixel_ratio_union",
  629. "contact_overlap_pixel_ratio_canvas",
  630. "contact_overlap_length_px_approx", "contact_overlap_length_ratio_centerline",
  631. "contact_centerline_length_px",
  632. "contact_overlap_pair_size_sum", "contact_overlap_pair_size_mean",
  633. "contact_overlap_pair_size_median", "contact_overlap_pair_size_max",
  634. "contact_overlap_pair_size_std", "contact_overlap_pair_size_q75",
  635. "contact_overlap_pair_size_q90", "contact_overlap_pair_size_q95",
  636. "contact_overlap_cc_count", "contact_overlap_cc_size_sum",
  637. "contact_overlap_cc_size_mean", "contact_overlap_cc_size_median",
  638. "contact_overlap_cc_size_max", "contact_overlap_cc_size_std",
  639. "contact_overlap_cc_size_q75", "contact_overlap_cc_size_q90",
  640. "contact_overlap_cc_size_q95",
  641. "contact_edge_contact_degree_mean", "contact_edge_contact_degree_median",
  642. "contact_edge_contact_degree_max", "contact_edge_contact_degree_std",
  643. "contact_edge_contact_degree_q75", "contact_edge_contact_degree_q90",
  644. "contact_edge_contact_degree_q95",
  645. ]
  646. def __init__(self, canvas_size=1024, thick=9, edge_margin=0.12,
  647. top_k=3, area_thresh=0.005, connectivity=8):
  648. self.canvas_size = canvas_size
  649. self.thick = thick
  650. self.edge_margin = edge_margin
  651. self.top_k = top_k
  652. self.area_thresh = area_thresh
  653. self.connectivity = connectivity
  654. # ------------------------------------------------------------------
  655. # Public entry points
  656. # ------------------------------------------------------------------
  657. def extract_from_path(self, filepath):
  658. """Load a JSON node-link file and emit its feature dictionary."""
  659. fp = Path(filepath)
  660. if not fp.exists():
  661. raise FileNotFoundError(f"Graph file not found: {filepath}")
  662. try:
  663. G = self._load_graph(fp)
  664. except (json.JSONDecodeError, KeyError) as e:
  665. raise ValueError(f"Invalid JSON format in {filepath}: {e}")
  666. return self._extract_features_from_graph(G)
  667. def extract_from_graph(self, G):
  668. """Identical to :meth:`extract_from_path` but accepts a live graph."""
  669. G_clean = self._deduplicate_graph(G)
  670. return self._extract_features_from_graph(G_clean)
  671. def _extract_features_from_graph(self, G):
  672. """Run all feature blocks in a fixed order and aggregate the result."""
  673. img, id2pt = self._render_image(G)
  674. feat = {}
  675. feat.update(self._basic_size(G))
  676. feat.update(self._degree_stats(G))
  677. feat.update(self._orientation_stats(G))
  678. feat.update(self._spatial_moments(G))
  679. feat.update(self._path_connectivity(G))
  680. feat.update(self._boundary_fractal(G))
  681. feat.update(self._redundancy_kcore(G))
  682. feat.update(self._cycle_features(G))
  683. feat.update(self._vertical_shortestness(G))
  684. feat.update(self._mesh_holes(img))
  685. feat.update(self._betweenness_edges(G))
  686. feat.update(self._pore_features(img))
  687. feat.update(self._contact_features(G, id2pt))
  688. return {k: feat[k] for k in self.FEATURE_COLS}
  689. def get_feature_names(self):
  690. return self.FEATURE_COLS.copy()
  691. def select_features(self, features, names):
  692. return {k: features[k] for k in names if k in features}
  693. def to_array(self, features):
  694. return np.array([features[k] for k in self.FEATURE_COLS], dtype=float)
  695. def to_dataframe(self, features, columns=None):
  696. if columns is None:
  697. columns = self.FEATURE_COLS
  698. return pd.DataFrame([{k: features[k] for k in columns}])
  699. # ------------------------------------------------------------------
  700. # Static numerical helpers
  701. # ------------------------------------------------------------------
  702. @staticmethod
  703. def _gini(x):
  704. """Gini coefficient on a non-negative 1-D array (heterogeneity proxy)."""
  705. if x.size == 0 or x.sum() == 0:
  706. return 0.0
  707. x = np.sort(x)
  708. n = x.size
  709. c = np.cumsum(x, dtype=float)
  710. return (n + 1 - 2 * (c / c[-1]).sum()) / n
  711. @staticmethod
  712. def _safe_stats(arr):
  713. """Return a NaN-safe summary dict (count/sum/mean/median/max/std/qXX)."""
  714. if len(arr) == 0:
  715. return dict(count=0, sum=0.0, mean=0.0, median=0.0, max=0.0,
  716. std=0.0, q75=0.0, q90=0.0, q95=0.0)
  717. a = np.asarray(arr, dtype=float)
  718. return dict(
  719. count=int(a.size), sum=float(a.sum()),
  720. mean=float(a.mean()), median=float(np.median(a)),
  721. max=float(a.max()), std=float(a.std(ddof=0)),
  722. q75=float(np.quantile(a, 0.75)),
  723. q90=float(np.quantile(a, 0.90)),
  724. q95=float(np.quantile(a, 0.95)),
  725. )
  726. @staticmethod
  727. def _cc_sizes_from_pixels(pixel_set, connectivity=8):
  728. """Iterative DFS connected-component labelling on a sparse pixel set."""
  729. if not pixel_set:
  730. return []
  731. visited = set()
  732. sizes = []
  733. nbr = ([(1,0),(-1,0),(0,1),(0,-1)] if connectivity == 4
  734. else [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])
  735. for px in pixel_set:
  736. if px in visited:
  737. continue
  738. stack = [px]
  739. visited.add(px)
  740. sz = 0
  741. while stack:
  742. y, x = stack.pop()
  743. sz += 1
  744. for dy, dx in nbr:
  745. nb = (y + dy, x + dx)
  746. if nb in pixel_set and nb not in visited:
  747. visited.add(nb)
  748. stack.append(nb)
  749. sizes.append(sz)
  750. return sizes
  751. @staticmethod
  752. def _get_edge_pixels(pt1, pt2, thick):
  753. """Local-AABB rasterisation of a single thick edge — used by the
  754. contact-feature block to avoid materialising the full canvas per
  755. edge, yielding O(|E| · w · L) memory rather than O(|E| · canvas²)."""
  756. x1, y1 = int(round(pt1[0])), int(round(pt1[1]))
  757. x2, y2 = int(round(pt2[0])), int(round(pt2[1]))
  758. m = thick + 2
  759. min_x, max_x = max(0, min(x1,x2) - m), max(x1,x2) + m
  760. min_y, max_y = max(0, min(y1,y2) - m), max(y1,y2) + m
  761. h, w = max_y - min_y + 1, max_x - min_x + 1
  762. if h <= 0 or w <= 0:
  763. return set()
  764. buf = np.zeros((h, w), dtype=np.uint8)
  765. cv2.line(buf, (x1-min_x, y1-min_y), (x2-min_x, y2-min_y),
  766. 255, thick, cv2.LINE_AA)
  767. ys, xs = np.where(buf > 0)
  768. return {(int(y+min_y), int(x+min_x)) for y, x in zip(ys, xs)}
  769. # ------------------------------------------------------------------
  770. # Graph I/O
  771. # ------------------------------------------------------------------
  772. def _load_graph(self, fp):
  773. data = json.loads(Path(fp).read_text())
  774. G0 = nx.Graph()
  775. for n in data["nodes"]:
  776. G0.add_node(n["id"], pos=tuple(n["pos"]))
  777. for e in data["links"]:
  778. G0.add_edge(e["source"], e["target"])
  779. return self._deduplicate_graph(G0)
  780. def _deduplicate_graph(self, G0):
  781. """Coalesce nodes sharing identical (x,y) — a side-effect of welding
  782. when two intersection points are computed twice with identical
  783. coordinates due to numerical coincidence."""
  784. pos0 = nx.get_node_attributes(G0, "pos")
  785. coord2ids = defaultdict(list)
  786. for nid, p in pos0.items():
  787. coord2ids[p].append(nid)
  788. G = nx.Graph()
  789. coords = list(coord2ids.keys())
  790. for i, c in enumerate(coords):
  791. G.add_node(i, pos=c)
  792. lookup = {old: coords.index(pos0[old]) for old in G0.nodes}
  793. for u, v in G0.edges:
  794. a, b = lookup[u], lookup[v]
  795. if a != b:
  796. G.add_edge(a, b)
  797. return G
  798. def _render_image(self, G):
  799. """Rasterise the graph onto a (canvas_size × canvas_size) grayscale
  800. canvas with anti-aliased line strokes of width ``self.thick``. The
  801. per-node pixel coordinates ``id2pt`` are returned alongside so the
  802. contact-feature block can reuse the exact same projection."""
  803. pos = np.array([G.nodes[n]["pos"] for n in G.nodes])
  804. min_xy, max_xy = pos.min(0), pos.max(0)
  805. span = (max_xy - min_xy).max() or 1
  806. scale = (self.canvas_size - 10) / span
  807. pts = ((pos - min_xy) * scale + 5).astype(int)
  808. id2pt = {n: tuple(p) for n, p in zip(G.nodes, pts)}
  809. img = np.ones((self.canvas_size, self.canvas_size), np.uint8) * 255
  810. for u, v in G.edges:
  811. cv2.line(img, id2pt[u], id2pt[v], 0, self.thick, cv2.LINE_AA)
  812. return img, id2pt
  813. # ------------------------------------------------------------------
  814. # Feature blocks
  815. # ------------------------------------------------------------------
  816. def _basic_size(self, G):
  817. """Cardinalities and length statistics of the fibre population."""
  818. pos = nx.get_node_attributes(G, "pos")
  819. lengths = np.array([math.dist(pos[u], pos[v]) for u, v in G.edges], dtype=float)
  820. nn, ne = G.number_of_nodes(), G.number_of_edges()
  821. return dict(
  822. n_node=nn, n_edge=ne,
  823. total_length=float(lengths.sum()),
  824. mean_edge_len=float(lengths.mean() if lengths.size else 0.0),
  825. len_cv=float(lengths.std()/lengths.mean()) if (lengths.size and lengths.mean()) else 0.0,
  826. )
  827. def _degree_stats(self, G):
  828. """Degree-parity counts and Shannon entropy of the degree law."""
  829. deg = np.array([d for _, d in G.degree()], dtype=int)
  830. p = np.bincount(deg) / deg.size
  831. return dict(
  832. deg2_count=int((deg == 2).sum()),
  833. deg4_count=int((deg == 4).sum()),
  834. degree_entropy=float(entropy(p[p > 0], base=2)),
  835. )
  836. def _orientation_stats(self, G):
  837. """Angular entropy and the planar anisotropy index from the second-
  838. order direction tensor Q = (1/|E|) Σ uᵢ uᵢᵀ. Anisotropy is defined
  839. as (λ₂ − λ₁) / (λ₁ + λ₂) where λ₁ ≤ λ₂ are the eigenvalues of Q."""
  840. pos = nx.get_node_attributes(G, "pos")
  841. ang = np.array([math.atan2(pos[v][1]-pos[u][1], pos[v][0]-pos[u][0])
  842. for u, v in G.edges], dtype=float)
  843. if ang.size == 0:
  844. return dict(orient_entropy=0.0, anisotropy=0.0)
  845. bins = np.histogram(ang, bins=18, range=(-math.pi, math.pi))[0]
  846. oe = float(entropy(bins[bins > 0], base=2))
  847. cs = np.column_stack([np.cos(ang), np.sin(ang)])
  848. Q = (cs.T @ cs) / ang.size
  849. eig = np.linalg.eigvalsh(Q)
  850. ani = float((eig[1]-eig[0]) / (eig[1]+eig[0]+1e-12))
  851. return dict(orient_entropy=oe, anisotropy=ani)
  852. def _spatial_moments(self, G):
  853. """Radius of gyration and degree-weighted first moment about the
  854. node-centroid — capture overall spatial spread and load-bearing
  855. mass distribution respectively."""
  856. pos = np.array([G.nodes[n]["pos"] for n in G.nodes])
  857. cen = pos.mean(0)
  858. deg = np.array([d for _, d in G.degree()])
  859. return dict(
  860. radius_gyration=float(np.sqrt(((pos-cen)**2).sum(1).mean())),
  861. moment_total=float(np.sum(np.linalg.norm(pos-cen, axis=1)*deg)),
  862. )
  863. def _path_connectivity(self, G):
  864. """Spectral and metric connectivity descriptors. λ_max is solved on
  865. a sparse Laplacian via Lanczos (eigsh, k=1) which scales linearly
  866. in |E|; the Fiedler value λ₂ uses NetworkX's algebraic_connectivity
  867. wrapper. The spectral-gap ratio λ₂ / λ_max is a scale-invariant
  868. proxy for global mixing."""
  869. cc_coef = float(nx.average_clustering(G))
  870. try:
  871. fiedler = float(nx.algebraic_connectivity(G))
  872. except nx.NetworkXError:
  873. fiedler = 0.0
  874. GC = G.subgraph(max(nx.connected_components(G), key=len))
  875. aspl = float(nx.average_shortest_path_length(GC)) if GC.number_of_nodes() > 1 else 0.0
  876. L = nx.laplacian_matrix(G).astype(float)
  877. try:
  878. lmax = float(eigsh(L, k=1, which="LA", return_eigenvectors=False)[0])
  879. except Exception:
  880. lmax = 0.0
  881. sgr = float(fiedler/(lmax+1e-12)) if lmax else 0.0
  882. return dict(clustering_coef=cc_coef, fiedler_value=fiedler,
  883. lambda_max=lmax, spectral_gap_ratio=sgr, aspl_giant=aspl)
  884. def _boundary_fractal(self, G):
  885. """Perimeter-edge fraction and box-counting fractal dimension over
  886. six dyadic scales. The slope of log N(s) vs log(1/s) is regressed
  887. by ordinary least squares and reported as the box dimension."""
  888. pos = np.array([G.nodes[n]["pos"] for n in G.nodes])
  889. ne = G.number_of_edges()
  890. eps = 1e-6
  891. xmin, xmax = pos[:,0].min(), pos[:,0].max()
  892. ymin, ymax = pos[:,1].min(), pos[:,1].max()
  893. bn = {n for n, (x,y) in nx.get_node_attributes(G, "pos").items()
  894. if abs(x-xmin)<eps or abs(x-xmax)<eps or abs(y-ymin)<eps or abs(y-ymax)<eps}
  895. be = [(u,v) for u,v in G.edges if u in bn or v in bn]
  896. bf = len(be)/ne if ne else 0.0
  897. sizes, counts = [], []
  898. for k in range(1, 7):
  899. s = 1/2**k
  900. idx = np.floor(pos/s).astype(int)
  901. counts.append(len({tuple(i) for i in idx}))
  902. sizes.append(1/s)
  903. fd = float(linregress(np.log(sizes), np.log(counts)).slope)
  904. return dict(boundary_frac=bf, fractal_dim_box=fd)
  905. def _redundancy_kcore(self, G):
  906. """Maxwell rigidity index R = |E| − 2|V| + 3, cyclomatic redundancy
  907. ratio, and the depth and core fraction of the k-core decomposition.
  908. Together these characterise the structural over-determination
  909. relevant to load-path robustness."""
  910. nn, ne = G.number_of_nodes(), G.number_of_edges()
  911. ri = float(ne - 2*nn + 3)
  912. rr = float((ne-nn+1)/ne) if ne else 0.0
  913. cn = nx.core_number(G)
  914. mk = max(cn.values())
  915. kf = float(sum(1 for v in cn.values() if v==mk)/nn)
  916. return dict(rigidity_index=ri, redundancy_ratio=rr, max_k_core=mk, kcore_frac=kf)
  917. def _cycle_features(self, G):
  918. """Triangle and 4-cycle population. Triangles are counted via
  919. Σ tri(v)/3; 4-cycles are enumerated through a spanning-tree cycle
  920. basis filtered by length."""
  921. ne = G.number_of_edges()
  922. tc = sum(nx.triangles(G).values())//3
  923. qc = len([c for c in nx.cycle_basis(G) if len(c)==4])
  924. return dict(triangle_count=tc, triangle_ratio=float(tc/ne) if ne else 0.0,
  925. quad_count=qc, quad_ratio=float(qc/ne) if ne else 0.0)
  926. def _vertical_shortestness(self, G):
  927. """Average top-to-bottom Dijkstra distance under |Δy| edge weights
  928. — a scale-invariant proxy for axial load-path tortuosity that
  929. correlates strongly with peak load under uniaxial tensile loading."""
  930. pos = nx.get_node_attributes(G, "pos")
  931. yv = np.array([p[1] for p in pos.values()])
  932. ymin, ymax = yv.min(), yv.max()
  933. eps = 1e-6
  934. top = [n for n,p in pos.items() if abs(p[1]-ymax)<eps]
  935. bot = [n for n,p in pos.items() if abs(p[1]-ymin)<eps]
  936. if not (top and bot):
  937. return dict(avg_shortest_dy=0.0, straightness=0.0)
  938. for u, v in G.edges:
  939. dy = abs(pos[u][1]-pos[v][1])
  940. G.edges[u,v]["w"] = dy if dy else 1e-6
  941. dists = []
  942. for s in top:
  943. d = nx.single_source_dijkstra_path_length(G, s, weight="w")
  944. dists.extend(d[t] for t in bot if t in d)
  945. avg = float(np.mean(dists)) if dists else 0.0
  946. return dict(avg_shortest_dy=avg, straightness=float(avg/(ymax-ymin+1e-12)))
  947. def _mesh_holes(self, img):
  948. """Connected-component statistics of the negative-space mesh holes,
  949. computed after a corner-seeded flood-fill that marks the unbounded
  950. exterior region in 8-connectivity."""
  951. res = self.canvas_size
  952. work = img.copy()
  953. cv2.floodFill(work, None, (0,0), 128)
  954. mask = work == 255
  955. if mask.sum() == 0:
  956. return dict(mesh_median_area=0.0, mesh_cv_area=0.0, mesh_max_area_ratio=0.0)
  957. _, _, st, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
  958. areas = st[1:, cv2.CC_STAT_AREA].astype(float)
  959. return dict(
  960. mesh_median_area=float(np.median(areas)),
  961. mesh_cv_area=float(np.std(areas)/areas.mean()) if areas.mean() else 0.0,
  962. mesh_max_area_ratio=float(areas.max()/(res*res)),
  963. )
  964. def _betweenness_edges(self, G):
  965. """Maximum and Gini concentration of edge betweenness centrality.
  966. For graphs with |V| > 2500 a Monte-Carlo k-source approximation
  967. with k=200 is used to keep the per-sample cost bounded."""
  968. if G.number_of_nodes() <= 2500:
  969. bc = nx.edge_betweenness_centrality(G, normalized=True)
  970. else:
  971. bc = nx.edge_betweenness_centrality(G, k=200, normalized=True, seed=0)
  972. vals = np.array(list(bc.values()))
  973. return dict(
  974. edge_betweenness_max=float(vals.max() if vals.size else 0.0),
  975. edge_betweenness_gini=float(self._gini(vals)),
  976. )
  977. def _pore_features(self, img):
  978. """Comprehensive pore-shape analytics: top-K convexity (area / hull
  979. area), circularity (4π·area / perimeter²), area-distribution
  980. moments, large-pore fractions and a 3×3 spatial-CV split."""
  981. res = self.canvas_size
  982. total_px = res*res
  983. work = img.copy()
  984. cv2.floodFill(work, None, (0,0), 128)
  985. mask = (work == 255).astype(np.uint8)
  986. cc_n, labels, st, cen = cv2.connectedComponentsWithStats(mask, 8)
  987. zero = dict(
  988. largest_pore_ratio=0.0, top_area_sum_ratio=0.0,
  989. top_convexity_min=1.0, top_convexity_mean=1.0, top_circularity_min=1.0,
  990. big_pore_count=0, total_pore_count=0,
  991. total_pore_ratio=0.0, center_pore_ratio=0.0, edge_pore_ratio=0.0,
  992. pore_area_cv=0.0, pore_area_skew=0.0, pore_area_kurtosis=0.0,
  993. pore_area_max_over_mean=1.0, pore_large_area_frac=0.0,
  994. pore_count_large_frac=0.0, pore_density=0.0, pore_spatial_cv=0.0,
  995. )
  996. if cc_n <= 1:
  997. return zero
  998. all_areas = st[1:, cv2.CC_STAT_AREA].astype(float)
  999. all_cxy = cen[1:]
  1000. n_pores = len(all_areas)
  1001. if n_pores >= 2:
  1002. mean_a = float(all_areas.mean())
  1003. std_a = float(all_areas.std(ddof=0))
  1004. pore_area_cv = std_a/mean_a if mean_a > 0 else 0.0
  1005. pore_area_skew = float(sp_skew(all_areas))
  1006. pore_area_kurtosis = float(sp_kurtosis(all_areas))
  1007. pore_area_max_over_mean = float(all_areas.max()/mean_a) if mean_a > 0 else 1.0
  1008. large_mask = all_areas > 2.0*mean_a
  1009. total_area_sum = float(all_areas.sum())
  1010. pore_large_area_frac = float(all_areas[large_mask].sum()/total_area_sum) if total_area_sum > 0 else 0.0
  1011. pore_count_large_frac = float(large_mask.sum()/n_pores)
  1012. else:
  1013. pore_area_cv = pore_area_skew = pore_area_kurtosis = 0.0
  1014. pore_area_max_over_mean = 1.0
  1015. pore_large_area_frac = pore_count_large_frac = 0.0
  1016. pore_density = float(n_pores/total_px)
  1017. grid_n = 3
  1018. grid_area = np.zeros((grid_n, grid_n))
  1019. for k in range(n_pores):
  1020. cx, cy = all_cxy[k]
  1021. xi = min(int(cx/res*grid_n), grid_n-1)
  1022. yi = min(int(cy/res*grid_n), grid_n-1)
  1023. grid_area[yi, xi] += all_areas[k]
  1024. nz = grid_area[grid_area > 0]
  1025. pore_spatial_cv = float(nz.std()/nz.mean()) if len(nz) > 1 else 0.0
  1026. new_feats = dict(
  1027. pore_area_cv=pore_area_cv, pore_area_skew=pore_area_skew,
  1028. pore_area_kurtosis=pore_area_kurtosis,
  1029. pore_area_max_over_mean=pore_area_max_over_mean,
  1030. pore_large_area_frac=pore_large_area_frac,
  1031. pore_count_large_frac=pore_count_large_frac,
  1032. pore_density=pore_density, pore_spatial_cv=pore_spatial_cv,
  1033. )
  1034. margin = res*self.edge_margin
  1035. cp, ep = [], []
  1036. for i in range(1, cc_n):
  1037. a = st[i, cv2.CC_STAT_AREA]
  1038. r = a/total_px
  1039. cx, cy = cen[i]
  1040. if margin < cx < res-margin and margin < cy < res-margin:
  1041. cp.append((i, a, r))
  1042. else:
  1043. ep.append((i, a, r))
  1044. tpc = len(cp)+len(ep)
  1045. tpr = (sum(a for _,a,_ in cp)+sum(a for _,a,_ in ep))/total_px
  1046. cpr = sum(a for _,a,_ in cp)/total_px
  1047. epr = sum(a for _,a,_ in ep)/total_px
  1048. big = sorted([(i,a,r) for i,a,r in cp if r >= self.area_thresh],
  1049. key=lambda x: x[1], reverse=True)
  1050. bpc = len(big)
  1051. top = big[:self.top_k]
  1052. base = dict(big_pore_count=bpc, total_pore_count=tpc,
  1053. total_pore_ratio=tpr, center_pore_ratio=cpr, edge_pore_ratio=epr)
  1054. if not top:
  1055. return {**zero, **base, **new_feats}
  1056. def _shape(lid):
  1057. m = (labels == lid).astype(np.uint8)
  1058. cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  1059. if not cnts:
  1060. return 1.0, 1.0
  1061. c = cnts[0]
  1062. ar = cv2.contourArea(c)
  1063. ha = cv2.contourArea(cv2.convexHull(c))
  1064. pe = cv2.arcLength(c, True)
  1065. return (ar/ha if ha else 1.0), (4*np.pi*ar/pe**2 if pe else 1.0)
  1066. shapes = [_shape(i) for i,_,_ in top]
  1067. ars = [r for _,_,r in top]
  1068. return dict(
  1069. largest_pore_ratio=ars[0], top_area_sum_ratio=sum(ars),
  1070. top_convexity_min=min(s[0] for s in shapes),
  1071. top_convexity_mean=float(np.mean([s[0] for s in shapes])),
  1072. top_circularity_min=min(s[1] for s in shapes),
  1073. **base, **new_feats)
  1074. def _contact_features(self, G, id2pt):
  1075. """Fibre-overlap analytics in pixel space.
  1076. Each edge is rasterised independently in its local AABB; pixel
  1077. ownership is then inverted into a (pixel → edge-list) map. Pixels
  1078. owned by ≥ 2 edges constitute the *raw* overlap set, while pairs of
  1079. non-adjacent (i.e. non-incident) edges contribute to the *true*
  1080. anchor-point regulation overlap, since incidence at a shared node
  1081. is not a stacking event but a topological junction. The block
  1082. emits per-pair size statistics (pr_st), connected-component size
  1083. statistics on the overlap pixel set (cc_st) and per-edge contact
  1084. degree statistics (ec_st), all routed through :meth:`_safe_stats`
  1085. for robustness on degenerate inputs."""
  1086. edges = list(G.edges())
  1087. E = len(edges)
  1088. N = G.number_of_nodes()
  1089. node_to_edges = defaultdict(set)
  1090. for i, (u, v) in enumerate(edges):
  1091. node_to_edges[u].add(i)
  1092. node_to_edges[v].add(i)
  1093. adj_pairs = set()
  1094. for eset in node_to_edges.values():
  1095. lst = list(eset)
  1096. for i in range(len(lst)):
  1097. for j in range(i+1, len(lst)):
  1098. adj_pairs.add(tuple(sorted((lst[i], lst[j]))))
  1099. edge_pixels = [self._get_edge_pixels(id2pt[u], id2pt[v], self.thick)
  1100. for u, v in edges]
  1101. ep_counts = [len(s) for s in edge_pixels]
  1102. ep_sum = int(np.sum(ep_counts))
  1103. ep_union = set().union(*edge_pixels) if edge_pixels else set()
  1104. ep_union_n = len(ep_union)
  1105. px2e = defaultdict(list)
  1106. for i, pxs in enumerate(edge_pixels):
  1107. for px in pxs:
  1108. px2e[px].append(i)
  1109. raw_olap = set()
  1110. olap = defaultdict(set)
  1111. for px, el in px2e.items():
  1112. if len(el) >= 2:
  1113. raw_olap.add(px)
  1114. for i in range(len(el)):
  1115. for j in range(i+1, len(el)):
  1116. pair = tuple(sorted((el[i], el[j])))
  1117. if pair not in adj_pairs:
  1118. olap[pair].add(px)
  1119. olap_all = set()
  1120. ecd = np.zeros(E, dtype=int)
  1121. for (a, b), pxs in olap.items():
  1122. if pxs:
  1123. olap_all.update(pxs)
  1124. ecd[a] += 1
  1125. ecd[b] += 1
  1126. olap_n = len(olap_all)
  1127. raw_n = len(raw_olap)
  1128. pair_n = len(olap)
  1129. cc_st = self._safe_stats(self._cc_sizes_from_pixels(olap_all, self.connectivity))
  1130. pr_st = self._safe_stats([len(p) for p in olap.values()])
  1131. ec_st = self._safe_stats(ecd.tolist())
  1132. cl_len = float(sum(math.dist(id2pt[u], id2pt[v]) for u, v in edges))
  1133. ca = self.canvas_size**2
  1134. ewc = int((ecd > 0).sum())
  1135. ol_approx = olap_n/max(self.thick, 1)
  1136. return {
  1137. "contact_thick": int(self.thick),
  1138. "contact_canvas_size": int(self.canvas_size),
  1139. "contact_nodes": N, "contact_edges": E,
  1140. "contact_edge_pixel_union_count": ep_union_n,
  1141. "contact_edge_pixel_sum": ep_sum,
  1142. "contact_raw_overlap_pixel_count": raw_n,
  1143. "contact_overlap_pixel_count": olap_n,
  1144. "contact_overlap_pair_count": pair_n,
  1145. "contact_overlap_pairs_per_edge": pair_n/E if E else 0.0,
  1146. "contact_edges_with_contact_count": ewc,
  1147. "contact_edges_with_contact_ratio": ewc/E if E else 0.0,
  1148. "contact_overlap_pixel_ratio_union": olap_n/ep_union_n if ep_union_n else 0.0,
  1149. "contact_raw_overlap_pixel_ratio_union": raw_n/ep_union_n if ep_union_n else 0.0,
  1150. "contact_overlap_pixel_ratio_canvas": olap_n/ca if ca else 0.0,
  1151. "contact_overlap_length_px_approx": ol_approx,
  1152. "contact_overlap_length_ratio_centerline": ol_approx/cl_len if cl_len else 0.0,
  1153. "contact_centerline_length_px": cl_len,
  1154. "contact_overlap_pair_size_sum": pr_st["sum"],
  1155. "contact_overlap_pair_size_mean": pr_st["mean"],
  1156. "contact_overlap_pair_size_median": pr_st["median"],
  1157. "contact_overlap_pair_size_max": pr_st["max"],
  1158. "contact_overlap_pair_size_std": pr_st["std"],
  1159. "contact_overlap_pair_size_q75": pr_st["q75"],
  1160. "contact_overlap_pair_size_q90": pr_st["q90"],
  1161. "contact_overlap_pair_size_q95": pr_st["q95"],
  1162. "contact_overlap_cc_count": cc_st["count"],
  1163. "contact_overlap_cc_size_sum": cc_st["sum"],
  1164. "contact_overlap_cc_size_mean": cc_st["mean"],
  1165. "contact_overlap_cc_size_median": cc_st["median"],
  1166. "contact_overlap_cc_size_max": cc_st["max"],
  1167. "contact_overlap_cc_size_std": cc_st["std"],
  1168. "contact_overlap_cc_size_q75": cc_st["q75"],
  1169. "contact_overlap_cc_size_q90": cc_st["q90"],
  1170. "contact_overlap_cc_size_q95": cc_st["q95"],
  1171. "contact_edge_contact_degree_mean": ec_st["mean"],
  1172. "contact_edge_contact_degree_median": ec_st["median"],
  1173. "contact_edge_contact_degree_max": ec_st["max"],
  1174. "contact_edge_contact_degree_std": ec_st["std"],
  1175. "contact_edge_contact_degree_q75": ec_st["q75"],
  1176. "contact_edge_contact_degree_q90": ec_st["q90"],
  1177. "contact_edge_contact_degree_q95": ec_st["q95"],
  1178. }
  1179. # %%
  1180. """
  1181. Quick_Start.ipynb — Cell 4: Surrogate load predictor (simplified test version)
  1182. ================================================================================
  1183. Lightweight inference wrapper around a *simplified* surrogate of the
  1184. load-prediction model described in Section 4.4. The artefacts shipped under
  1185. ``model/`` are a compact stand-in trained on a reduced feature subset; they
  1186. exist solely to keep this notebook self-contained and end-to-end runnable
  1187. without requiring the full GNN training corpus.
  1188. The complete pipeline — including the Pearson |r| > 0.8 decorrelation
  1189. sweep, the canonical non-collinear feature subset selection, the
  1190. cross-validated hyper-parameter search, and the convex blending of the
  1191. two component regressors — is implemented separately in
  1192. ``CV_fold_train.py``; the present cell only restores the persisted
  1193. artefacts and exposes a uniform forward interface used by the SAC inner
  1194. loop (Cell 5) and the visualisation routine (Cell 6).
  1195. Expected layout under ``model/``:
  1196. et_model.joblib — first component regressor
  1197. gbr_model.joblib — second component regressor
  1198. simplified config.json — {feature_cols, w_et, w_gbr, n_features}
  1199. """
  1200. import json
  1201. import joblib
  1202. import numpy as np
  1203. from pathlib import Path
  1204. class LoadPredictor:
  1205. """
  1206. Convex blend of two complementary regressors,
  1207. F̂(G) = w₁ · M₁(G) + w₂ · M₂(G),
  1208. with w₁ + w₂ = 1. The blending coefficients are persisted in the
  1209. config file and were tuned offline against a held-out validation
  1210. fold during the training stage; this class is strictly an inference
  1211. wrapper and performs no fitting.
  1212. Two ingestion modes are exposed:
  1213. * :meth:`predict` — accepts a path to a node-link JSON
  1214. file produced by Cell 1.
  1215. * :meth:`predict_from_graph` — accepts a live NetworkX graph; this
  1216. is the throughput-critical entry point
  1217. invoked from inside the reinforcement-
  1218. learning loop, where the JSON round-
  1219. trip would otherwise dominate latency.
  1220. """
  1221. def __init__(self, model_dir, extractor=None):
  1222. self.model_dir = Path(model_dir)
  1223. self.extractor = extractor
  1224. self._load_models()
  1225. def _load_models(self):
  1226. cfg_path = self.model_dir / "simplified config.json"
  1227. if not cfg_path.exists():
  1228. raise FileNotFoundError(f"Config file not found: {cfg_path}")
  1229. cfg = json.loads(cfg_path.read_text())
  1230. # ``feature_cols`` is the ordered subset that the surrogate was
  1231. # trained on — it is **not** the full FEATURE_COLS list of Cell 3
  1232. # but rather the canonical subset retained after decorrelation.
  1233. self.feature_cols = cfg["feature_cols"]
  1234. self.w_et = cfg["w_et"]
  1235. self.w_gbr = cfg["w_gbr"]
  1236. self.n_features = cfg["n_features"]
  1237. self.et = joblib.load(self.model_dir / "et_model.joblib")
  1238. self.gbr = joblib.load(self.model_dir / "gbr_model.joblib")
  1239. def predict(self, json_path, extractor=None):
  1240. """Infer the surrogate load given a path to a node-link JSON file."""
  1241. _extractor = extractor or self.extractor
  1242. if _extractor is None:
  1243. raise ValueError("Extractor required as parameter or during initialization")
  1244. features = _extractor.extract_from_path(json_path)
  1245. X = np.array([features[col] for col in self.feature_cols]).reshape(1, -1)
  1246. p1 = self.et.predict(X)[0]
  1247. p2 = self.gbr.predict(X)[0]
  1248. return float(self.w_et * p1 + self.w_gbr * p2)
  1249. def predict_from_graph(self, G, extractor=None):
  1250. """Identical to :meth:`predict` but accepts a live NetworkX graph,
  1251. avoiding the JSON round-trip inside latency-sensitive callers."""
  1252. _extractor = extractor or self.extractor
  1253. if _extractor is None:
  1254. raise ValueError("Extractor required as parameter or during initialization")
  1255. features = _extractor.extract_from_graph(G)
  1256. X = np.array([features[col] for col in self.feature_cols]).reshape(1, -1)
  1257. p1 = self.et.predict(X)[0]
  1258. p2 = self.gbr.predict(X)[0]
  1259. return float(self.w_et * p1 + self.w_gbr * p2)
  1260. # %%
  1261. """
  1262. Quick_Start.ipynb — Cell 5: SAC inverse-design optimisation (simplified)
  1263. ==========================================================================
  1264. Compact reproduction of the reinforcement-learning optimiser introduced in
  1265. Section 4.5. This cell is a *simplified* demonstrator whose
  1266. sole purpose is to verify that the surrogate-driven inverse-design loop
  1267. executes end-to-end on a fresh installation in well under a minute. Every
  1268. hyper-parameter and structural choice below has a more elaborate
  1269. counterpart in the production scripts (``RL-1.py`` and ``RL-2.py``), where
  1270. multi-seed roll-outs, prioritised replay, the strict mass-window
  1271. constraint of 6.5 ± 0.05 g and the Loss-Weight / Fixed-Weight mode
  1272. switching of §4.5 are all exercised.
  1273. Reward design
  1274. -------------
  1275. The agent supports the standard SAC interface, which means the reward
  1276. function is a free design choice rather than a hard requirement of the
  1277. algorithm. Several formulations are sensible here, including:
  1278. * a Pareto-flavoured composition of relative load gain and relative
  1279. length reduction with an explicit corner bonus when both axes
  1280. improve simultaneously — the form actually instantiated below;
  1281. * a normalised target-tracking reward of the form r = w / w_target −
  1282. λ · l / l_target, useful when explicit target values are known;
  1283. * a constraint-aware variant in which length (or mass) enters as a
  1284. hard barrier rather than as a soft penalty.
  1285. The form used in this cell — r = α·Δw + β·Δl + bonus − penalty — was
  1286. chosen for its smoothness and its tolerance to the noisy single-step
  1287. gradients that arise when only twenty episodes per encoding are
  1288. available. For longer-horizon production runs, swapping in any of the
  1289. alternative formulations above only requires editing :meth:`WeldEnv.step`.
  1290. Discount factor
  1291. ---------------
  1292. ``γ`` is set to zero in the SAC constructor. This is **not** an intrinsic
  1293. requirement of the method; it is a deliberate simplification matched to
  1294. the single-step episode structure used here for fast notebook execution.
  1295. With γ = 0 the target-network bootstrapping degenerates into a pure
  1296. reward regression — perfectly adequate for the present demonstration but
  1297. discarded in favour of the standard discounted formulation in the
  1298. production training scripts whenever multi-step roll-outs are used.
  1299. """
  1300. import os, json, time, copy, random, math, itertools
  1301. import numpy as np
  1302. import networkx as nx
  1303. import torch
  1304. import torch.nn as nn
  1305. import torch.nn.functional as F
  1306. from collections import deque
  1307. from shapely.geometry import LineString, Point
  1308. def edge_lengths(G):
  1309. """Return (mean, std, sum) of edge lengths over a positioned graph."""
  1310. pos = nx.get_node_attributes(G, 'pos')
  1311. l = [math.hypot(*(np.subtract(pos[u], pos[v]))) for u, v in G.edges()]
  1312. if not l:
  1313. return 0., 0., 0.
  1314. a = np.asarray(l, float)
  1315. return float(a.mean()), float(a.std()), float(a.sum())
  1316. def total_edge_length(G):
  1317. """Total fibre length — the proxy for material mass under the constant-
  1318. cross-section, constant-density assumptions of §4.5."""
  1319. return edge_lengths(G)[2]
  1320. class GraphBuilder:
  1321. """
  1322. Compact in-memory reproduction of the five-stage TOPNet construction
  1323. pipeline (square base → C4 deformation → tiling → welding → cleanup).
  1324. Functionally equivalent to :class:`SquareGraphGenerator` from Cell 1
  1325. but stripped of all I/O — sub-millisecond regeneration is required
  1326. here because :meth:`build` is called once per environment step.
  1327. """
  1328. def __init__(self, s=10, k=5, t=3, sc=1):
  1329. self.s, self.k, self.t, self.sc = s, k, t, sc
  1330. def base_square(self):
  1331. G = nx.Graph()
  1332. v = {'A':(0,0), 'B':(self.s,0), 'C':(self.s,self.s), 'D':(0,self.s)}
  1333. for n, p in v.items():
  1334. G.add_node(n, pos=p)
  1335. for a, b in [('A','B'),('B','C'),('C','D'),('D','A')]:
  1336. G.add_edge(a, b)
  1337. pa, pb = np.array(v[a]), np.array(v[b])
  1338. for j in range(1, self.k+1):
  1339. tt = j/(self.k+1)
  1340. p = (1-tt)*pa + tt*pb
  1341. n = f"{a}{b}{j}"
  1342. G.add_node(n, pos=(float(p[0]), float(p[1])))
  1343. G.add_edge(a if j==1 else f"{a}{b}{j-1}", n)
  1344. if j == self.k:
  1345. G.add_edge(n, b)
  1346. return G
  1347. def apply_offsets(self, G, o):
  1348. """Apply the 10-D encoding under C4 rotational replication."""
  1349. H = nx.Graph()
  1350. H.add_nodes_from(G.nodes(data=True))
  1351. sh = {}
  1352. for i in range(1, self.k+1):
  1353. dx, dy = o[2*(i-1)], o[2*(i-1)+1]
  1354. dx *= self.s; dy *= self.s
  1355. sh[f'AB{i}'] = ( dx, dy)
  1356. sh[f'BC{i}'] = (-dy, dx)
  1357. sh[f'CD{i}'] = (-dx, -dy)
  1358. sh[f'DA{i}'] = ( dy, -dx)
  1359. for n, (ox, oy) in sh.items():
  1360. if n in H.nodes:
  1361. x, y = H.nodes[n]['pos']
  1362. H.nodes[n]['pos'] = (x+ox, y+oy)
  1363. H.remove_edges_from(list(H.edges()))
  1364. ns = sorted(H.nodes())
  1365. for i in range(len(ns)):
  1366. H.add_edge(ns[i], ns[(i+1) % len(ns)])
  1367. return H
  1368. def tile_scale(self, G):
  1369. H = nx.Graph()
  1370. p = nx.get_node_attributes(G, 'pos')
  1371. sf = self.sc/(self.s*self.t)
  1372. for i in range(self.t):
  1373. for j in range(self.t):
  1374. for n, (x, y) in p.items():
  1375. H.add_node(f"{n}_{i}_{j}", pos=((x+i*self.s)*sf, (y+j*self.s)*sf))
  1376. for i in range(self.t):
  1377. for j in range(self.t):
  1378. for u, v in G.edges():
  1379. H.add_edge(f"{u}_{i}_{j}", f"{v}_{i}_{j}")
  1380. return H
  1381. def weld(self, G):
  1382. H = copy.deepcopy(G)
  1383. e = list(H.edges())
  1384. b = {}
  1385. for (u1,v1),(u2,v2) in itertools.combinations(e, 2):
  1386. p1,p2 = H.nodes[u1]['pos'], H.nodes[v1]['pos']
  1387. q1,q2 = H.nodes[u2]['pos'], H.nodes[v2]['pos']
  1388. L1, L2 = LineString([p1,p2]), LineString([q1,q2])
  1389. if L1.intersects(L2):
  1390. pt = L1.intersection(L2)
  1391. if pt.geom_type == "Point":
  1392. x, y = float(pt.x), float(pt.y)
  1393. n = f"IX_{x:.6f}_{y:.6f}"
  1394. H.add_node(n, pos=(x,y))
  1395. b.setdefault((u1,v1),[]).append((x,y))
  1396. b.setdefault((u2,v2),[]).append((x,y))
  1397. ne = []
  1398. for u, v in e:
  1399. pts = b.get((u,v), [])
  1400. if not pts:
  1401. ne.append((u,v)); continue
  1402. P, Q = H.nodes[u]['pos'], H.nodes[v]['pos']
  1403. L = LineString([P, Q])
  1404. pv = u
  1405. for x, y in sorted(pts, key=lambda t: L.project(Point(t))):
  1406. n = f"IX_{x:.6f}_{y:.6f}"
  1407. ne.append((pv, n)); pv = n
  1408. ne.append((pv, v))
  1409. H.remove_edges_from(e)
  1410. H.add_edges_from(ne)
  1411. return H
  1412. def clean(self, G):
  1413. H = nx.Graph()
  1414. H.add_nodes_from(G.nodes(data=True))
  1415. for u, v in G.edges():
  1416. if G.nodes[u]['pos'] != G.nodes[v]['pos']:
  1417. H.add_edge(u, v)
  1418. return H
  1419. def build(self, o):
  1420. """End-to-end pipeline: base → offsets → tile → weld → clean."""
  1421. g = self.base_square()
  1422. g = self.apply_offsets(g, o)
  1423. g = self.tile_scale(g)
  1424. g = self.weld(g)
  1425. g = self.clean(g)
  1426. return g
  1427. class WeldEnv:
  1428. """
  1429. Single-step, OpenAI-Gym-style environment wrapping ``GraphBuilder``
  1430. and ``LoadPredictor``. Each call to :meth:`step` regenerates the
  1431. welded network from the perturbed encoding, queries the surrogate,
  1432. and emits a scalar reward.
  1433. The reward function here is one of several admissible choices for
  1434. this dual-objective problem (see the cell-level docstring for a
  1435. discussion of the alternatives). It composes the relative load gain
  1436. Δw and the relative length reduction Δl with α/β weights, augments
  1437. the result with a "BOTH" corner bonus whenever both objectives
  1438. strictly improve, and applies a soft penalty when length reduction
  1439. falls below a tolerance threshold:
  1440. Δw = (load − w₀) / |w₀|
  1441. Δl = (l₀ − length) / |l₀|
  1442. r = α · Δw + β · Δl
  1443. + bonus_both if Δw > 0 and Δl > 0
  1444. − penalty_l if Δl < −l_thresh
  1445. The episode horizon is fixed to one step (terminal = True) because
  1446. the decision variable — the 10-D encoding — fully specifies the
  1447. topology, and there is no temporal credit-assignment problem to
  1448. solve in this simplified setting.
  1449. """
  1450. def __init__(self, builder, predictor, base,
  1451. act_bound=0.3, p_low=-0.5, p_high=0.5,
  1452. alpha=5.0, beta=8.0, bonus_both=4.0,
  1453. penalty_l=3.0, l_thresh=0.05):
  1454. self.builder = builder
  1455. self.predictor = predictor
  1456. self.base = np.array(base, dtype=np.float32)
  1457. self.dim = len(base)
  1458. self.act_bound = act_bound
  1459. self.p_low, self.p_high = p_low, p_high
  1460. self.alpha, self.beta = alpha, beta
  1461. self.bonus_both = bonus_both
  1462. self.penalty_l = penalty_l
  1463. self.l_thresh = l_thresh
  1464. # Cache the baseline metrics once at construction time so all
  1465. # subsequent rewards are evaluated relatively against this anchor.
  1466. G0 = builder.build(self.base)
  1467. self.hb = float(predictor.predict_from_graph(G0))
  1468. self.l0 = float(total_edge_length(G0))
  1469. self.g = G0
  1470. self.p = self.base.copy()
  1471. @property
  1472. def obs_dim(self): return self.dim
  1473. @property
  1474. def act_dim(self): return self.dim
  1475. def reset(self):
  1476. self.p = self.base.copy()
  1477. self.g = self.builder.build(self.p)
  1478. return self.p.copy()
  1479. def step(self, action):
  1480. # Action ∈ [-1, 1]^d (post-tanh) is rescaled by ``act_bound`` and
  1481. # added to the base encoding. The result is hard-clipped onto the
  1482. # admissible parameter box [-0.5, 0.5]^d.
  1483. a = action * self.act_bound
  1484. self.p = np.clip(self.base + a, self.p_low, self.p_high).astype(np.float32)
  1485. self.g = self.builder.build(self.p)
  1486. load = float(self.predictor.predict_from_graph(self.g))
  1487. length = float(total_edge_length(self.g))
  1488. dw = (load - self.hb) / max(abs(self.hb), 1e-6)
  1489. dl = (self.l0 - length) / max(abs(self.l0), 1e-6)
  1490. r = self.alpha * dw + self.beta * dl
  1491. both = bool(dw > 0 and dl > 0)
  1492. if both:
  1493. r += self.bonus_both
  1494. if dl < -self.l_thresh:
  1495. r -= self.penalty_l
  1496. info = {"w": load, "l": length, "dw": dw, "dl": dl, "both": both}
  1497. return self.p.copy(), float(r), True, info
  1498. class ReplayBuffer:
  1499. """Uniformly sampled FIFO replay buffer."""
  1500. def __init__(self, cap=50000):
  1501. self.buf = deque(maxlen=cap)
  1502. def push(self, s, a, r, s2, d):
  1503. self.buf.append((s.copy(), a.copy(), r, s2.copy(), d))
  1504. def sample(self, bs):
  1505. batch = random.sample(self.buf, bs)
  1506. s, a, r, s2, d = zip(*batch)
  1507. return (torch.FloatTensor(np.array(s)),
  1508. torch.FloatTensor(np.array(a)),
  1509. torch.FloatTensor(np.array(r)).unsqueeze(1),
  1510. torch.FloatTensor(np.array(s2)),
  1511. torch.FloatTensor(np.array(d)).unsqueeze(1))
  1512. def __len__(self): return len(self.buf)
  1513. class Actor(nn.Module):
  1514. """Squashed-Gaussian policy with reparameterised sampling
  1515. a = tanh(μ + σ·ε), ε ~ N(0, I), enforcing a bounded action space."""
  1516. def __init__(self, obs_dim, act_dim, hid=256, log_std_min=-20, log_std_max=2):
  1517. super().__init__()
  1518. self.log_std_min, self.log_std_max = log_std_min, log_std_max
  1519. self.fc1 = nn.Linear(obs_dim, hid)
  1520. self.fc2 = nn.Linear(hid, hid)
  1521. self.mu = nn.Linear(hid, act_dim)
  1522. self.log_std = nn.Linear(hid, act_dim)
  1523. def forward(self, x):
  1524. x = F.relu(self.fc1(x)); x = F.relu(self.fc2(x))
  1525. mu = self.mu(x)
  1526. log_std = self.log_std(x).clamp(self.log_std_min, self.log_std_max)
  1527. return mu, log_std
  1528. def sample(self, obs):
  1529. mu, log_std = self.forward(obs)
  1530. std = log_std.exp()
  1531. dist = torch.distributions.Normal(mu, std)
  1532. z = dist.rsample()
  1533. action = torch.tanh(z)
  1534. # Tanh-Jacobian correction in the log-density.
  1535. log_prob = (dist.log_prob(z)
  1536. - torch.log(1 - action.pow(2) + 1e-6)).sum(-1, keepdim=True)
  1537. return action, log_prob
  1538. class Critic(nn.Module):
  1539. """Twin Q-networks (clipped double-Q) to mitigate over-estimation bias."""
  1540. def __init__(self, obs_dim, act_dim, hid=256):
  1541. super().__init__()
  1542. dim_in = obs_dim + act_dim
  1543. self.q1 = nn.Sequential(nn.Linear(dim_in,hid), nn.ReLU(),
  1544. nn.Linear(hid,hid), nn.ReLU(),
  1545. nn.Linear(hid,1))
  1546. self.q2 = nn.Sequential(nn.Linear(dim_in,hid), nn.ReLU(),
  1547. nn.Linear(hid,hid), nn.ReLU(),
  1548. nn.Linear(hid,1))
  1549. def forward(self, s, a):
  1550. x = torch.cat([s, a], dim=-1)
  1551. return self.q1(x), self.q2(x)
  1552. class SAC:
  1553. """
  1554. Soft Actor-Critic with automatic temperature tuning.
  1555. The discount factor ``γ`` is exposed as a constructor argument and
  1556. defaults to zero here purely as a convenience for the single-step
  1557. notebook demonstration; any value in [0, 1) is supported and is
  1558. routinely used in the production scripts under longer horizons. The
  1559. Polyak coefficient τ = 5e-3 and the update-to-data ratio ``utd``
  1560. follow standard SAC defaults.
  1561. """
  1562. def __init__(self, obs_dim, act_dim,
  1563. lr=3e-4, gamma=0.0, tau=0.005,
  1564. alpha_init=0.2, auto_alpha=True,
  1565. batch_size=64, warmup=64, utd=4, buf_cap=50000):
  1566. self.gamma, self.tau = gamma, tau
  1567. self.bs, self.warmup, self.utd = batch_size, warmup, utd
  1568. self.actor = Actor(obs_dim, act_dim)
  1569. self.critic = Critic(obs_dim, act_dim)
  1570. self.critic_target = copy.deepcopy(self.critic)
  1571. self.actor_opt = torch.optim.Adam(self.actor.parameters(), lr=lr)
  1572. self.critic_opt = torch.optim.Adam(self.critic.parameters(), lr=lr)
  1573. self.auto_alpha = auto_alpha
  1574. if auto_alpha:
  1575. # Target entropy heuristic from Haarnoja et al. (2018).
  1576. self.target_entropy = -float(act_dim)
  1577. self.log_alpha = torch.tensor(np.log(alpha_init), requires_grad=True)
  1578. self.alpha_opt = torch.optim.Adam([self.log_alpha], lr=lr)
  1579. self.alpha = self.log_alpha.exp().item()
  1580. else:
  1581. self.alpha = alpha_init
  1582. self.rb = ReplayBuffer(buf_cap)
  1583. def select_action(self, obs, deterministic=False):
  1584. with torch.no_grad():
  1585. obs_t = torch.FloatTensor(obs).unsqueeze(0)
  1586. if deterministic:
  1587. mu, _ = self.actor(obs_t)
  1588. return torch.tanh(mu).squeeze(0).numpy()
  1589. a, _ = self.actor.sample(obs_t)
  1590. return a.squeeze(0).numpy()
  1591. def _update_once(self):
  1592. s, a, r, s2, d = self.rb.sample(self.bs)
  1593. with torch.no_grad():
  1594. a2, logp2 = self.actor.sample(s2)
  1595. q1t, q2t = self.critic_target(s2, a2)
  1596. qt = torch.min(q1t, q2t) - self.alpha * logp2
  1597. target = r + self.gamma * (1.0 - d) * qt
  1598. q1, q2 = self.critic(s, a)
  1599. critic_loss = F.mse_loss(q1, target) + F.mse_loss(q2, target)
  1600. self.critic_opt.zero_grad(); critic_loss.backward(); self.critic_opt.step()
  1601. a_new, logp_new = self.actor.sample(s)
  1602. q1_new, q2_new = self.critic(s, a_new)
  1603. actor_loss = (self.alpha * logp_new - torch.min(q1_new, q2_new)).mean()
  1604. self.actor_opt.zero_grad(); actor_loss.backward(); self.actor_opt.step()
  1605. if self.auto_alpha:
  1606. alpha_loss = -(self.log_alpha.exp()
  1607. * (logp_new.detach() + self.target_entropy)).mean()
  1608. self.alpha_opt.zero_grad(); alpha_loss.backward(); self.alpha_opt.step()
  1609. self.alpha = self.log_alpha.exp().item()
  1610. # Polyak averaging of the target critics.
  1611. for p, pt in zip(self.critic.parameters(), self.critic_target.parameters()):
  1612. pt.data.copy_(self.tau * p.data + (1 - self.tau) * pt.data)
  1613. def update(self):
  1614. if len(self.rb) < self.warmup: return
  1615. for _ in range(self.utd):
  1616. self._update_once()
  1617. def run_optimization(base_seq, seq_idx, ep_max=20, seed=42):
  1618. """Single-encoding optimisation entry point.
  1619. Constructs a fresh SAC agent and environment around the supplied
  1620. base encoding, runs ``ep_max`` one-step episodes, and returns the
  1621. best-reward record encountered along the trajectory.
  1622. """
  1623. random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
  1624. extractor = GraphFeatureExtractor()
  1625. predictor = LoadPredictor(
  1626. model_dir=r".\model",
  1627. extractor=extractor,
  1628. )
  1629. builder = GraphBuilder()
  1630. base = np.array(base_seq, dtype=np.float32)
  1631. env = WeldEnv(builder, predictor, base)
  1632. sac = SAC(env.obs_dim, env.act_dim)
  1633. print(f"[seq {seq_idx:03d}] base=[{', '.join(f'{v:+.2f}' for v in base)}] "
  1634. f"w0={env.hb:.2f} l0={env.l0:.2f}")
  1635. best_r = -float("inf")
  1636. best_rec = None
  1637. n_both = 0
  1638. for ep in range(1, ep_max+1):
  1639. s = env.reset()
  1640. a = sac.select_action(s)
  1641. s2, r, _, info = env.step(a)
  1642. sac.rb.push(s, a, r, s2, 1.0)
  1643. sac.update()
  1644. if info["both"]:
  1645. n_both += 1
  1646. if r > best_r:
  1647. best_r = r
  1648. best_rec = {
  1649. "seq_idx": seq_idx,
  1650. "ep": ep,
  1651. "r": float(r),
  1652. "w": float(info["w"]),
  1653. "l": float(info["l"]),
  1654. "both": info["both"],
  1655. "base": base.tolist(),
  1656. "params": env.p.tolist(),
  1657. "delta": (env.p - base).tolist(),
  1658. }
  1659. flag = " [BOTH]" if best_rec["both"] else ""
  1660. print(f" best r={best_r:+.4f} w={best_rec['w']:.2f} "
  1661. f"l={best_rec['l']:.2f} both_rate={n_both/ep_max:.0%}{flag}")
  1662. return best_rec
  1663. # ---------------------------------------------------------------------------
  1664. # Driver: optimise every encoding produced by Cell 1 and persist the
  1665. # trajectory log for downstream visualisation and 3-D mapping.
  1666. # ---------------------------------------------------------------------------
  1667. EP_MAX = 20
  1668. SEQ_SEED = 42
  1669. all_results = []
  1670. OPTIMIZED_PARAMS = []
  1671. print("=" * 80)
  1672. print(f"SAC optimization-Simplified Test Version sequences={len(DX_DY_SEQUENCES)} ep_per_seq={EP_MAX}")
  1673. print("=" * 80)
  1674. for idx, seq in enumerate(DX_DY_SEQUENCES):
  1675. rec = run_optimization(seq, seq_idx=idx, ep_max=EP_MAX, seed=SEQ_SEED+idx)
  1676. all_results.append(rec)
  1677. OPTIMIZED_PARAMS.append(np.array(rec["params"], dtype=np.float32))
  1678. both_found = [r for r in all_results if r["both"]]
  1679. best_overall = max(all_results, key=lambda r: r["r"])
  1680. print("\n" + "=" * 80)
  1681. print(f"Done. sequences={len(all_results)} "
  1682. f"both_found={len(both_found)}/{len(all_results)}")
  1683. print(f"best overall: seq={best_overall['seq_idx']} "
  1684. f"r={best_overall['r']:+.4f} "
  1685. f"w={best_overall['w']:.2f} l={best_overall['l']:.2f}")
  1686. print("=" * 80)
  1687. ts = time.strftime("%Y%m%d_%H%M%S")
  1688. path = f"sac_opt_{ts}.json"
  1689. with open(path, 'w') as f:
  1690. json.dump({"ep_max": EP_MAX, "results": all_results}, f, indent=2)
  1691. print(f"saved -> {path}")
  1692. # %%
  1693. %matplotlib inline
  1694. # %%
  1695. """
  1696. Quick_Start.ipynb — Cell 6: Before/after side-by-side visualisation
  1697. ====================================================================
  1698. Renders each base encoding alongside its SAC-optimised counterpart so that
  1699. the relative load gain (Δw) and the relative material reduction (Δl) can be
  1700. inspected at a glance. The titles colour-code samples for which both
  1701. objectives strictly improved (the "BOTH" condition of §4.5).
  1702. """
  1703. import matplotlib.pyplot as plt
  1704. import numpy as np
  1705. # Re-instantiate the surrogate stack independently of Cell 5 so this cell
  1706. # can be re-run without re-executing the optimisation loop.
  1707. _extractor = GraphFeatureExtractor()
  1708. _predictor = LoadPredictor(model_dir=r".\model", extractor=_extractor)
  1709. _builder = GraphBuilder()
  1710. n_seq = len(all_results)
  1711. fig, axes = plt.subplots(n_seq, 2, figsize=(8, 4 * n_seq), squeeze=False)
  1712. fig.patch.set_facecolor("white")
  1713. for idx, rec in enumerate(all_results):
  1714. base_p = np.array(rec["base"], dtype=np.float32)
  1715. opt_p = np.array(rec["params"], dtype=np.float32)
  1716. G_before = _builder.build(base_p)
  1717. G_after = _builder.build(opt_p)
  1718. w_before = _predictor.predict_from_graph(G_before)
  1719. l_before = total_edge_length(G_before)
  1720. w_after = rec["w"]
  1721. l_after = rec["l"]
  1722. # Per-sample relative deltas, expressed in percent.
  1723. dw = (w_after - w_before) / max(abs(w_before), 1e-6) * 100
  1724. dl = (l_after - l_before) / max(abs(l_before), 1e-6) * 100
  1725. for col, (G, label, w, l, delta) in enumerate([
  1726. (G_before, "Before", w_before, l_before, None),
  1727. (G_after, "After", w_after, l_after, (dw, dl)),
  1728. ]):
  1729. ax = axes[idx][col]
  1730. ax.set_facecolor("white")
  1731. pos = nx.get_node_attributes(G, "pos")
  1732. for u, v in G.edges():
  1733. ax.plot(
  1734. [pos[u][0], pos[v][0]],
  1735. [pos[u][1], pos[v][1]],
  1736. color="black", linewidth=1.2,
  1737. )
  1738. ax.set_aspect("equal")
  1739. ax.axis("off")
  1740. if delta is None:
  1741. title = f"Seq {rec['seq_idx']} — {label}\nLoad={w:.3f} Len={l:.4f}"
  1742. color = "black"
  1743. else:
  1744. tag = " ✓ BOTH" if rec["both"] else ""
  1745. title = (f"Seq {rec['seq_idx']} — {label}{tag}\n"
  1746. f"Load={w:.3f} ({dw:+.1f}%) Len={l:.4f} ({dl:+.1f}%)")
  1747. color = "#007700" if rec["both"] else "black"
  1748. ax.set_title(title, fontsize=8, color=color, fontfamily="monospace", pad=4)
  1749. plt.suptitle(
  1750. f"SAC Optimization | {n_seq} seq | "
  1751. f"both={sum(r['both'] for r in all_results)}/{n_seq}",
  1752. fontsize=10,
  1753. )
  1754. plt.tight_layout()
  1755. # %%
  1756. """
  1757. Quick_Start.ipynb — Cell 7: 3-D surface mapping onto a quad mesh
  1758. ==================================================================
  1759. Demonstrator of the surface-mapping technique described in Section 4.6 of
  1760. the manuscript. The optimised 10-D encoding produced by Cell 5 is mapped
  1761. isomorphically onto every quadrilateral facet of a curved target surface
  1762. that has been pre-processed by QuadriFlow into an all-quad mesh, thereby
  1763. transferring the planar regular fibrous network architecture onto an
  1764. arbitrary 2-manifold.
  1765. Pipeline
  1766. --------
  1767. (i) QuadMesh: parse a Wavefront OBJ file emitted by QuadriFlow and
  1768. construct the dual incidence map E -> (f₁, f₂) used by the
  1769. deformer to build a smooth tangent/normal frame across shared
  1770. edges (cf. §4.6 "encoding and deformation" stage).
  1771. (ii) MeshEdgeDeformer: for each edge-face pair, build a local
  1772. orthonormal frame {e₁, e₂_in, e₂_out} from the face normal and
  1773. the edge tangent, then displace the five interior control points
  1774. along (e₁, e₂) using the optimised encoding. The sign of dy
  1775. selects the in-plane vs out-of-plane normal so the deformation
  1776. covers both sides of the manifold and reproduces the rotational
  1777. replication scheme of the planar generator.
  1778. (iii) GraphShot: orthographic projection-based renderer with a small
  1779. basis of canonical viewpoints, used for figure preparation.
  1780. For full solid export (sphere-tube fattening + global rescale + OBJ
  1781. write-out as required for FDM/SLA fabrication), see ``Graph2OBJ.py`` —
  1782. this cell only performs the topology mapping and a flat-projection
  1783. preview.
  1784. """
  1785. %matplotlib inline
  1786. import pathlib as _pp
  1787. import numpy as _np
  1788. import networkx as _nx
  1789. import numpy as np
  1790. import networkx as nx
  1791. import matplotlib.pyplot as plt
  1792. import matplotlib.image as mpimg
  1793. import tempfile
  1794. from collections import defaultdict, Counter
  1795. from pathlib import Path
  1796. from typing import Tuple, Union
  1797. from IPython.display import Image, display
  1798. OBJ_PATH = _pp.Path(r".\3_Lung_quad_400.obj")
  1799. assert OBJ_PATH.exists(), f"{OBJ_PATH} not found"
  1800. print("OBJ file size:", OBJ_PATH.stat().st_size / 1024, "KB")
  1801. class QuadMesh:
  1802. """
  1803. Minimal Wavefront-OBJ loader specialised for all-quad meshes.
  1804. Builds two synchronised representations:
  1805. * ``self.V``, ``self.F`` — vertex / face arrays in OBJ order;
  1806. * ``self.G`` — NetworkX graph, with one node per
  1807. vertex and a quad-cycle edge for each
  1808. face. Non-quad faces are silently
  1809. skipped, matching QuadriFlow's
  1810. guarantee of an all-quad output.
  1811. """
  1812. def __init__(self, path):
  1813. self.path = path; self.V = []; self.F = []; self.G = _nx.Graph()
  1814. def parse_obj(self):
  1815. for ln in self.path.open():
  1816. if ln.startswith("v "):
  1817. _, x, y, z = ln.split()
  1818. self.V.append(_np.array([float(x), float(y), float(z)]))
  1819. elif ln.startswith("f "):
  1820. # OBJ uses 1-based indexing; strip any vt/vn suffixes.
  1821. idx = [int(tok.split("/")[0]) - 1 for tok in ln.split()[1:]]
  1822. self.F.append(idx)
  1823. def build_graph(self):
  1824. for i, p in enumerate(self.V):
  1825. self.G.add_node(i, pos=p)
  1826. for f in self.F:
  1827. if len(f) != 4:
  1828. continue
  1829. self.G.add_edges_from([(f[j - 1], f[j]) for j in range(4)])
  1830. def summary(self):
  1831. n_face = len(self.F); n_quad = sum(len(f) == 4 for f in self.F)
  1832. print(f"total faces {n_face}, quads {n_quad}, non-quad {n_face - n_quad}")
  1833. print("graph nodes", self.G.number_of_nodes(), "edges", self.G.number_of_edges())
  1834. qm = QuadMesh(OBJ_PATH)
  1835. qm.parse_obj()
  1836. qm.build_graph()
  1837. qm.summary()
  1838. # ---------------------------------------------------------------------------
  1839. # Build the edge -> incident-faces map. In a closed orientable quad mesh
  1840. # every interior edge is incident to exactly two faces and every edge of
  1841. # every face is referenced exactly four times across all incident edges
  1842. # (each face has four edges, each contributing one reference). The two
  1843. # sanity checks below catch boundary holes and non-manifold defects in the
  1844. # QuadriFlow output before any deformation is attempted.
  1845. # ---------------------------------------------------------------------------
  1846. edge2faces = defaultdict(list)
  1847. for fid, f in enumerate(qm.F):
  1848. if len(f) != 4:
  1849. continue
  1850. for a, b in ((f[0], f[1]), (f[1], f[2]), (f[2], f[3]), (f[3], f[0])):
  1851. e = (a, b) if a < b else (b, a)
  1852. edge2faces[e].append(fid)
  1853. qm.G.clear_edges()
  1854. for (u, v), fs in edge2faces.items():
  1855. qm.G.add_edge(u, v, faces=tuple(fs))
  1856. missing_edges = [e for e, d in qm.G.edges.items() if not d["faces"]]
  1857. face_use = Counter(fid for _, _, d in qm.G.edges(data=True) for fid in d["faces"])
  1858. wrong_faces = [fid for fid, c in face_use.items() if c != 4]
  1859. print("edges without face tag:", len(missing_edges))
  1860. print("faces not referenced 4 times:", len(wrong_faces))
  1861. class MeshEdgeDeformer:
  1862. """
  1863. Encoding-driven displacement of every quad-edge interior point.
  1864. For each (edge, face) pair the deformer constructs the local
  1865. orthonormal frame
  1866. e₁ = tangent along the edge (oriented by face winding),
  1867. e₂_in = e₁ × n_face (in-plane normal),
  1868. e₂_out = e₁ × n_avg (out-of-plane normal, smoothed
  1869. across the two incident faces),
  1870. and displaces the five interior control points by
  1871. p_k = p_u + t_k · L · e₁
  1872. + dx_k · L · e₁
  1873. + dy_k · L · (e₂_out if dy_k > 0 else e₂_in).
  1874. The sign-dependent choice of e₂ ensures the network alternates between
  1875. inward and outward stacking, mirroring the planar two-sided regulation
  1876. discussed in §4.1.
  1877. A topological invariant is enforced as a post-condition: every face
  1878. must end up referenced exactly ``SEG_PER_FACE = 4 · (k+1)`` times by
  1879. edge segments after subdivision.
  1880. """
  1881. def __init__(self, qm, disp5=None):
  1882. self.qm = qm
  1883. self.disp5 = disp5 if disp5 is not None else [(0, 0)] * 5
  1884. self.V = np.asarray(qm.V)
  1885. self.Fn = [self._quad_normal(q) for q in qm.F]
  1886. self.edge_faces = edge2faces
  1887. self.N_SEG_EDGE = len(self.disp5) + 1
  1888. self.SEG_PER_FACE = 4 * self.N_SEG_EDGE
  1889. self.G = nx.Graph()
  1890. for vid, p in enumerate(self.V):
  1891. self.G.add_node(vid, pos=p)
  1892. self.next_pid = len(self.V)
  1893. def _unit(self, v):
  1894. n = np.linalg.norm(v); return v / n if n else v
  1895. def _quad_normal(self, quad):
  1896. """Right-hand-rule unit normal of a quad, computed from the first
  1897. two non-collinear edge vectors."""
  1898. a, b, c, _ = self.V[quad]; return self._unit(np.cross(b - a, c - a))
  1899. def _orient_edge(self, fid, a, b):
  1900. """Return (a, b) in the order matching the winding of face fid."""
  1901. q = self.qm.F[fid]
  1902. for i in range(4):
  1903. if q[i] == a and q[(i + 1) % 4] == b: return a, b
  1904. if q[i] == b and q[(i + 1) % 4] == a: return b, a
  1905. raise RuntimeError
  1906. def _add_inner_points(self, fid, u0, v0, faces_lst):
  1907. u, v = self._orient_edge(fid, u0, v0)
  1908. p_u, p_v = self.V[u], self.V[v]
  1909. Lvec = p_v - p_u; L = np.linalg.norm(Lvec)
  1910. e1 = self._unit(Lvec)
  1911. n_face = self.Fn[fid]
  1912. # Smooth the normal across the shared edge to avoid creasing
  1913. # artefacts near sharp dihedral angles.
  1914. if len(faces_lst) == 2:
  1915. fid2 = faces_lst[0] if faces_lst[1] == fid else faces_lst[1]
  1916. n_avg = self._unit(n_face + self.Fn[fid2])
  1917. else:
  1918. n_avg = n_face
  1919. e2_in = self._unit(np.cross(n_face, e1))
  1920. e2_out = self._unit(np.cross(n_avg, e1))
  1921. inner = []
  1922. for k, (dx, dy) in enumerate(self.disp5, 1):
  1923. t = k / (len(self.disp5) + 1)
  1924. base = p_u + t * Lvec
  1925. e2 = e2_out if dy > 0 else e2_in
  1926. pos = base + dx * L * e1 + dy * L * e2
  1927. pid = self.next_pid; self.next_pid += 1
  1928. self.G.add_node(pid, pos=pos)
  1929. inner.append(pid)
  1930. seq = [u] + inner + [v]
  1931. for a, b in zip(seq[:-1], seq[1:]):
  1932. self.G.add_edge(a, b, faces=(fid,))
  1933. def build(self):
  1934. for u, v, d in self.qm.G.edges(data=True):
  1935. faces_lst = d["faces"]
  1936. for fid in faces_lst:
  1937. self._add_inner_points(fid, u, v, faces_lst)
  1938. def _check_segments(self):
  1939. """Topological invariant: each face must be referenced exactly
  1940. ``SEG_PER_FACE`` times by post-subdivision edge segments."""
  1941. cnt = Counter(fid for _, _, d in self.G.edges(data=True) for fid in d["faces"])
  1942. bad = [fid for fid, c in cnt.items() if c != self.SEG_PER_FACE]
  1943. if bad: raise RuntimeError(f"Segment count error on faces {bad[:10]}")
  1944. print(f"nodes {self.G.number_of_nodes()} edges {self.G.number_of_edges()} all checks passed")
  1945. def run(self):
  1946. self.build()
  1947. self._check_segments()
  1948. return self.G
  1949. class GraphShot:
  1950. """
  1951. Orthographic-projection screenshot utility for 3-D fibrous networks.
  1952. Maps a 3-D NetworkX graph onto a 2-D image plane defined by a
  1953. user-selected canonical view direction and an in-plane roll angle, then
  1954. rasterises every edge as a thin polyline. Used here for the
  1955. before/after preview only; the high-fidelity solid export is delegated
  1956. to the Graph2OBJ utility.
  1957. """
  1958. EDGE_COLOR = "#C008F8"
  1959. PRESETS = {
  1960. "iso": (np.array([1.0, 1.0, 1.0]), np.array([0.0, 0.0, 1.0])),
  1961. "+x": (np.array([1.0, 0.0, 0.0]), np.array([0.0, 0.0, 1.0])),
  1962. "-x": (np.array([-1.0, 0.0, 0.0]), np.array([0.0, 0.0, 1.0])),
  1963. "+y": (np.array([0.0, 1.0, 0.0]), np.array([0.0, 0.0, 1.0])),
  1964. "-y": (np.array([0.0, -1.0, 0.0]), np.array([0.0, 0.0, 1.0])),
  1965. "+z": (np.array([0.0, 0.0, 1.0]), np.array([0.0, 1.0, 0.0])),
  1966. "-z": (np.array([0.0, 0.0, -1.0]), np.array([0.0, 1.0, 0.0])),
  1967. }
  1968. def __init__(self, G: nx.Graph, view: str, *, roll_deg: float = 0.0,
  1969. zoom: float = 1.0, img_size: Tuple[int, int] = (400, 400),
  1970. out_path: Union[str, Path]):
  1971. self.view = view
  1972. self.roll_deg = roll_deg
  1973. self.zoom = zoom
  1974. self.img_size = img_size
  1975. self.out_path = Path(out_path)
  1976. order = sorted(G.nodes())
  1977. self.node_idx = {n: i for i, n in enumerate(order)}
  1978. self.pts = np.asarray([G.nodes[n]["pos"] for n in order], float)
  1979. self.edges = [(self.node_idx[u], self.node_idx[v]) for u, v in G.edges()]
  1980. def _project(self):
  1981. """Construct an orthonormal screen basis (right, up_orth) such that
  1982. the view direction d is normal to the image plane, then project the
  1983. 3-D vertex set and apply the roll rotation in screen space."""
  1984. d_raw, up_raw = self.PRESETS[self.view]
  1985. d = d_raw / np.linalg.norm(d_raw)
  1986. up = up_raw / np.linalg.norm(up_raw)
  1987. right = np.cross(up, d)
  1988. if np.linalg.norm(right) < 1e-10:
  1989. right = np.array([1.0, 0.0, 0.0])
  1990. right = right / np.linalg.norm(right)
  1991. up_orth = np.cross(d, right)
  1992. up_orth = up_orth / np.linalg.norm(up_orth)
  1993. sx = self.pts @ right
  1994. sy = self.pts @ up_orth
  1995. rad = np.deg2rad(self.roll_deg)
  1996. c, s = np.cos(rad), np.sin(rad)
  1997. return c * sx - s * sy, s * sx + c * sy
  1998. def shoot(self, show_png: bool = True):
  1999. sx, sy = self._project()
  2000. dpi = 100
  2001. w, h = self.img_size
  2002. fig, ax = plt.subplots(figsize=(w / dpi, h / dpi), dpi=dpi)
  2003. fig.patch.set_facecolor("white")
  2004. ax.set_facecolor("white")
  2005. for ui, vi in self.edges:
  2006. ax.plot([sx[ui], sx[vi]], [sy[ui], sy[vi]],
  2007. color=self.EDGE_COLOR, linewidth=0.4, rasterized=True)
  2008. ax.set_aspect("equal")
  2009. ax.axis("off")
  2010. fig.savefig(str(self.out_path), dpi=dpi, bbox_inches="tight",
  2011. facecolor="white", pad_inches=0)
  2012. plt.close(fig)
  2013. if show_png:
  2014. display(Image(filename=self.out_path))
  2015. return self.out_path
  2016. # ---------------------------------------------------------------------------
  2017. # Driver: pull the best optimised encoding from Cell 5, deform the loaded
  2018. # quad mesh under both the zero-displacement reference and the optimised
  2019. # encoding, and render a side-by-side preview.
  2020. # ---------------------------------------------------------------------------
  2021. VIEW, ROLL, ZOOM = "-z", 0, 1.5
  2022. rec = all_results[0]
  2023. opt_p = np.array(rec["params"], dtype=np.float64)
  2024. disp5_opt = [(float(opt_p[2 * i]), float(opt_p[2 * i + 1])) for i in range(5)]
  2025. with tempfile.TemporaryDirectory() as _tmp:
  2026. tmp = Path(_tmp)
  2027. # Reference (zero-displacement) mapping.
  2028. d0 = MeshEdgeDeformer(qm, disp5=[(0.0, 0.0)] * 5)
  2029. G0 = d0.run()
  2030. GraphShot(G0, view=VIEW, roll_deg=ROLL, zoom=ZOOM,
  2031. img_size=(400, 400), out_path=tmp / "zero.png").shoot(show_png=False)
  2032. # SAC-optimised mapping.
  2033. d1 = MeshEdgeDeformer(qm, disp5=disp5_opt)
  2034. G1 = d1.run()
  2035. GraphShot(G1, view=VIEW, roll_deg=ROLL, zoom=ZOOM,
  2036. img_size=(400, 400), out_path=tmp / "opt.png").shoot(show_png=False)
  2037. img0 = mpimg.imread(str(tmp / "zero.png"))
  2038. img1 = mpimg.imread(str(tmp / "opt.png"))
  2039. fig, axes = plt.subplots(1, 2, figsize=(9, 5))
  2040. fig.patch.set_facecolor("white")
  2041. axes[0].imshow(img0); axes[0].axis("off")
  2042. axes[1].imshow(img1); axes[1].axis("off")
  2043. plt.tight_layout()

Quick_Start.ipynb at commit cade4e0, under MIT · at the source

Overview

Authors: Yunhao Yang1,2,3, Jing Ren2,3, Leitao Cao3, Xuankai Zhang3, Chen Huang3, Xinquan Jiang1, Shengjie Ling1,2,3
  1. Shanghai Stomatological Hospital & School of Stomatology, Fudan University,Shanghai, China
  2. State Key Laboratory of Molecular Engineering of Polymers, Research Center of AI for Polymer Science, Department of Macromolecular Science, Fudan University,Shanghai, China
  3. School of Physical Science and Technology, ShanghaiTech University,Shanghai, China
Institutions: Fudan University (China); ShanghaiTech University (China)
Journal: Nature communications, volume 17, issue 1, article 9237
Dates: received 18 November 2025; accepted 17 July 2026; published online 30 July 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41467-026-76045-x · PMID 42669705 · PMCID PMC13526844 · OpenAlex W7171846633
Open access: gold, a free copy (OpenAlex)
Status: code verified
Methods: Connectivity, Graphs, Machine learning
Keywords: Mechanical properties, Mechanical engineering
Topic: 3D Shape Modeling and Analysis (Computational Mechanics, Engineering), according to OpenAlex
Funding: National Natural Science Foundation of China (National Science Foundation of China) (52322305, 52473098, 52503135); National Key R&D Program of China (2025YFA0923503)
Citations: not cited yet (Europe PMC); 43 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

Its files are read in the Code ↔ Paper reader above, with 21 matches between paragraphs and lines of code.

Zenodo 20228872

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
At the source:

BMG-FDU/REFINe

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: cade4e089cb4dcfa0f9e15545ec3b8cec43fd5b2, 20 May 2026
Languages: Python (16), Jupyter (3)
Size: 536 files, 19 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (environment.yml), 3 notebooks
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (12 files), NetworkX (7 files), Matplotlib (5 files), PyTorch (5 files), OpenCV (4 files), pandas (4 files), SciPy (3 files), Pillow (2 files), PyTorch Geometric (1 file), scikit-learn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
21 files

Code availability statement

The paper has a code availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41467-026-76045-x.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 19 scripts, each with its path and the digest of its content;
  • 21 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability statement

The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

  • no repository, dataset or request procedure was recognized in it

Read it in the paper: doi.org/10.1038/s41467-026-76045-x.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 7 authors, 2 keywords, 2 funders, 39 references.

Cite

This paper

Yang, Y., Ren, J., Cao, L., Zhang, X., Huang, C., Jiang, X., & Ling, S. (2026). A manufacturability-informed topology framework for AI-guided design of fibrous network materials. Nature communications, 17(1), 9237. https://doi.org/10.1038/s41467-026-76045-x

BibTeX

@article{yang2026manufacturability,
author = {Yang, Yunhao and Ren, Jing and Cao, Leitao and Zhang, Xuankai and Huang, Chen and Jiang, Xinquan and Ling, Shengjie},
title = {{A manufacturability-informed topology framework for AI-guided design of fibrous network materials}},
journal = {Nature communications},
year = {2026},
month = jul,
volume = {17},
number = {1},
pages = {9237},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-76045-x},
url = {https://doi.org/10.1038/s41467-026-76045-x},
pmid = {42669705},
pmcid = {PMC13526844}
}

RIS

TY - JOUR
AU - Yang, Yunhao
AU - Ren, Jing
AU - Cao, Leitao
AU - Zhang, Xuankai
AU - Huang, Chen
AU - Jiang, Xinquan
AU - Ling, Shengjie
TI - A manufacturability-informed topology framework for AI-guided design of fibrous network materials
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/07/30
VL - 17
IS - 1
SP - 9237
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-76045-x
UR - https://doi.org/10.1038/s41467-026-76045-x
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-76045-x",
"type": "article-journal",
"title": "A manufacturability-informed topology framework for AI-guided design of fibrous network materials",
"container-title": "Nature communications",
"author": [
{
"family": "Yang",
"given": "Yunhao"
},
{
"family": "Ren",
"given": "Jing"
},
{
"family": "Cao",
"given": "Leitao"
},
{
"family": "Zhang",
"given": "Xuankai"
},
{
"family": "Huang",
"given": "Chen"
},
{
"family": "Jiang",
"given": "Xinquan"
},
{
"family": "Ling",
"given": "Shengjie"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "9237",
"DOI": "10.1038/s41467-026-76045-x",
"PMID": "42669705",
"PMCID": "PMC13526844",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-76045-x",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
30
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.7554/elife.110074 [code]
Disentangling cephalopod chromatophores motor units with computer vision.
Journal: eLife
In common: NetworkX, OpenCV, Pillow, 6 other tools, 2 references
[2] doi:10.1038/s41398-026-03965-z [code]
Disentangling individual heterogeneity reveals robust network and molecular signatures of major depressive disorder with suicidal ideation.
Journal: Translational psychiatry
In common: PyTorch Geometric, NetworkX, OpenCV, 7 other tools
[3] doi:10.1002/hbm.70469 [code]
VarCoNet: A Variability-Aware Self-Supervised Framework for Functional Connectome Extraction From Resting-State fMRI.
Journal: Human brain mapping
In common: PyTorch Geometric, NetworkX, OpenCV, 7 other tools
[4] doi:10.1371/journal.pcbi.1013441 [code]
Large vision model framework for automated C. elegans analysis: From static morphometry to dynamic neural activity.
Journal: PLoS computational biology
In common: NetworkX, OpenCV, Pillow, 5 other tools, 2 references
[5] doi:10.1371/journal.pcbi.1014571 [code]
SynAPSeg: A novel dataset and image analysis framework for deep learning-based synapse detection and quantification.
Journal: PLoS computational biology
In common: NetworkX, OpenCV, Pillow, 6 other tools, 1 reference
[6] doi:10.1093/bioinformatics/btag553 [code]
Spatial-spectral fusion enables drug repositioning by capturing indirect and long-range associations in biological networks.
Journal: Bioinformatics (Oxford, England)
In common: PyTorch Geometric, NetworkX, OpenCV, 6 other tools
[7] doi:10.1093/bib/bbag118 [code]
Drug screening for α-synuclein aggregation inhibitors via multimodal graph neural network.
Journal: Briefings in bioinformatics
In common: PyTorch Geometric, NetworkX, Pillow, 6 other tools
[8] doi:10.21203/rs.3.rs-9676637/v1 [code]
A Comprehensive Benchmarking of Spatial Deconvolution and Domain Detection Methods across Diverse Tissues and Spatial Transcriptomic Technologies
Journal: Research Square (preprint)
In common: PyTorch Geometric, OpenCV, Pillow, 6 other tools
[9] doi:10.1002/advs.75969 [code]
Accurately Deciphering Tissue Heterogeneity From Spatial Multi-Modal and Multi-Omics With STransformer.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: PyTorch Geometric, OpenCV, Pillow, 6 other tools
[10] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: NetworkX, OpenCV, Pillow, 6 other tools

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.