combaero-gui 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. combaero_gui-0.2.0/PKG-INFO +73 -0
  2. combaero_gui-0.2.0/README.md +48 -0
  3. combaero_gui-0.2.0/__init__.py +0 -0
  4. combaero_gui-0.2.0/backend/__init__.py +0 -0
  5. combaero_gui-0.2.0/backend/graph_builder.py +443 -0
  6. combaero_gui-0.2.0/backend/main.py +359 -0
  7. combaero_gui-0.2.0/backend/schemas.py +340 -0
  8. combaero_gui-0.2.0/backend/units.py +135 -0
  9. combaero_gui-0.2.0/combaero_gui.egg-info/PKG-INFO +73 -0
  10. combaero_gui-0.2.0/combaero_gui.egg-info/SOURCES.txt +76 -0
  11. combaero_gui-0.2.0/combaero_gui.egg-info/dependency_links.txt +1 -0
  12. combaero_gui-0.2.0/combaero_gui.egg-info/entry_points.txt +2 -0
  13. combaero_gui-0.2.0/combaero_gui.egg-info/requires.txt +5 -0
  14. combaero_gui-0.2.0/combaero_gui.egg-info/top_level.txt +1 -0
  15. combaero_gui-0.2.0/frontend/.gitignore +24 -0
  16. combaero_gui-0.2.0/frontend/README.md +38 -0
  17. combaero_gui-0.2.0/frontend/eslint.config.js +23 -0
  18. combaero_gui-0.2.0/frontend/index.html +13 -0
  19. combaero_gui-0.2.0/frontend/package.json +44 -0
  20. combaero_gui-0.2.0/frontend/pnpm-lock.yaml +3344 -0
  21. combaero_gui-0.2.0/frontend/postcss.config.js +6 -0
  22. combaero_gui-0.2.0/frontend/public/favicon.svg +1 -0
  23. combaero_gui-0.2.0/frontend/public/icons.svg +24 -0
  24. combaero_gui-0.2.0/frontend/src/App.css +1 -0
  25. combaero_gui-0.2.0/frontend/src/App.tsx +169 -0
  26. combaero_gui-0.2.0/frontend/src/api.ts +48 -0
  27. combaero_gui-0.2.0/frontend/src/assets/hero.png +0 -0
  28. combaero_gui-0.2.0/frontend/src/assets/react.svg +1 -0
  29. combaero_gui-0.2.0/frontend/src/assets/vite.svg +1 -0
  30. combaero_gui-0.2.0/frontend/src/components/AreaInput.tsx +56 -0
  31. combaero_gui-0.2.0/frontend/src/components/CompositionEditor.tsx +225 -0
  32. combaero_gui-0.2.0/frontend/src/components/DiagnosticsPanel.tsx +231 -0
  33. combaero_gui-0.2.0/frontend/src/components/FlowEdge.tsx +53 -0
  34. combaero_gui-0.2.0/frontend/src/components/Inspector.tsx +1700 -0
  35. combaero_gui-0.2.0/frontend/src/components/LengthInput.tsx +63 -0
  36. combaero_gui-0.2.0/frontend/src/components/NetworkCanvas.tsx +141 -0
  37. combaero_gui-0.2.0/frontend/src/components/NodeDiagRows.tsx +62 -0
  38. combaero_gui-0.2.0/frontend/src/components/NumericInput.tsx +107 -0
  39. combaero_gui-0.2.0/frontend/src/components/Sidebar.tsx +383 -0
  40. combaero_gui-0.2.0/frontend/src/components/SolverStatusBadge.tsx +54 -0
  41. combaero_gui-0.2.0/frontend/src/components/SurfaceEnhancementInspector.tsx +280 -0
  42. combaero_gui-0.2.0/frontend/src/components/ThermalEdge.tsx +171 -0
  43. combaero_gui-0.2.0/frontend/src/components/UnitInput.tsx +53 -0
  44. combaero_gui-0.2.0/frontend/src/components/WallLayersEditor.tsx +198 -0
  45. combaero_gui-0.2.0/frontend/src/components/flowTypes.ts +32 -0
  46. combaero_gui-0.2.0/frontend/src/components/nodes/AreaChangeNode.tsx +73 -0
  47. combaero_gui-0.2.0/frontend/src/components/nodes/ChannelNode.tsx +63 -0
  48. combaero_gui-0.2.0/frontend/src/components/nodes/CombustorNode.tsx +61 -0
  49. combaero_gui-0.2.0/frontend/src/components/nodes/DiscreteLossNode.tsx +78 -0
  50. combaero_gui-0.2.0/frontend/src/components/nodes/LosslessNode.tsx +40 -0
  51. combaero_gui-0.2.0/frontend/src/components/nodes/MassBoundaryNode.tsx +52 -0
  52. combaero_gui-0.2.0/frontend/src/components/nodes/MomentumChamberNode.tsx +61 -0
  53. combaero_gui-0.2.0/frontend/src/components/nodes/OrificeNode.tsx +63 -0
  54. combaero_gui-0.2.0/frontend/src/components/nodes/PlenumNode.tsx +61 -0
  55. combaero_gui-0.2.0/frontend/src/components/nodes/PressureBoundaryNode.tsx +54 -0
  56. combaero_gui-0.2.0/frontend/src/components/nodes/ProbeNode.tsx +124 -0
  57. combaero_gui-0.2.0/frontend/src/index.css +154 -0
  58. combaero_gui-0.2.0/frontend/src/main.tsx +10 -0
  59. combaero_gui-0.2.0/frontend/src/node-25-regression.test.ts +55 -0
  60. combaero_gui-0.2.0/frontend/src/store/useStore.ts +424 -0
  61. combaero_gui-0.2.0/frontend/src/utils/diagnostics.ts +96 -0
  62. combaero_gui-0.2.0/frontend/src/utils/nodeUtils.ts +11 -0
  63. combaero_gui-0.2.0/frontend/src/utils/quantities.ts +125 -0
  64. combaero_gui-0.2.0/frontend/src/utils/validation.ts +102 -0
  65. combaero_gui-0.2.0/frontend/tailwind.config.js +8 -0
  66. combaero_gui-0.2.0/frontend/tsconfig.app.json +28 -0
  67. combaero_gui-0.2.0/frontend/tsconfig.json +7 -0
  68. combaero_gui-0.2.0/frontend/tsconfig.node.json +26 -0
  69. combaero_gui-0.2.0/frontend/vite.config.ts +7 -0
  70. combaero_gui-0.2.0/frontend/vitest.config.ts +11 -0
  71. combaero_gui-0.2.0/pyproject.toml +53 -0
  72. combaero_gui-0.2.0/setup.cfg +4 -0
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: combaero-gui
3
+ Version: 0.2.0
4
+ Summary: CombAero Network GUI — React/FastAPI desktop application
5
+ Author-email: thiemom <2641939+thiemom@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/thiemom/combaero
8
+ Project-URL: Repository, https://github.com/thiemom/combaero
9
+ Project-URL: Bug Tracker, https://github.com/thiemom/combaero/issues
10
+ Project-URL: Changelog, https://github.com/thiemom/combaero/blob/main/CHANGELOG.md
11
+ Keywords: aerodynamics,combustion,aerothermal,propulsion,gui,network
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi
21
+ Requires-Dist: uvicorn
22
+ Requires-Dist: pydantic
23
+ Requires-Dist: pandas
24
+ Requires-Dist: combaero~=0.2
25
+
26
+ # CombAero Network Designer GUI
27
+
28
+ **Interactive design and diagnostic environment for fluid-thermal networks.**
29
+
30
+ The CombAero GUI allows for rapid construction of complex aerospace networks using a drag-and-drop interface powered by React Flow and a high-performance C++ backend.
31
+
32
+ ## Quick Start
33
+
34
+ ### 1. Environment Setup
35
+ Ensure the GUI components are installed:
36
+ ```bash
37
+ uv pip install -e ".[gui]"
38
+ ```
39
+
40
+ ### 2. Launch
41
+ The GUI can be launched with a single command:
42
+ ```bash
43
+ combaero-gui
44
+ ```
45
+ This starts both the FastAPI backend and the Vite-based frontend.
46
+
47
+ ## Key Workflows
48
+
49
+ - **Build**: Drag nodes (Plenums, Boundaries) and elements (Orifices, Channels) from the sidebar.
50
+ - **Connect**: Click and drag from node handles to element ports.
51
+ - **Solve**: Click the **Solve** button in the header. Successful solves turn node borders green.
52
+ - **Inspect**: Click on any node or element to view real-time diagnostics in the Inspector sidebar.
53
+
54
+ ---
55
+
56
+ ## Agent Reference (UI Automation Map)
57
+
58
+ For agents with screen control (multimodal LLMs), the following selectors and IDs are provided to assist in navigation:
59
+
60
+ | UI Component | Selector / ID | Description |
61
+ | :--- | :--- | :--- |
62
+ | **Solve Button** | `#solve-btn` | Triggers the network solve. |
63
+ | **Inspector Sidebar** | `.inspector-panel` | Properties for selected node/element. |
64
+ | **Network Canvas** | `#rf-canvas` | The main React Flow workspace. |
65
+ | **Node (General)** | `.react-flow__node` | Any node on the canvas. |
66
+ | **Solved Indicator** | `.solved-status-ok` | Appears on nodes after a successful solve. |
67
+ | **Error Console** | `#error-output` | Displays solver convergence failures. |
68
+
69
+ > [!TIP]
70
+ > **Automation Hint**: To add a node via script, use the `gui.backend` API or generate a network JSON and load it via `File -> Import`.
71
+
72
+ ## Technical Reference
73
+ For internal data structures and JSON schemas, see [docs/GUI_TECHNICAL.md](../docs/GUI_TECHNICAL.md).
@@ -0,0 +1,48 @@
1
+ # CombAero Network Designer GUI
2
+
3
+ **Interactive design and diagnostic environment for fluid-thermal networks.**
4
+
5
+ The CombAero GUI allows for rapid construction of complex aerospace networks using a drag-and-drop interface powered by React Flow and a high-performance C++ backend.
6
+
7
+ ## Quick Start
8
+
9
+ ### 1. Environment Setup
10
+ Ensure the GUI components are installed:
11
+ ```bash
12
+ uv pip install -e ".[gui]"
13
+ ```
14
+
15
+ ### 2. Launch
16
+ The GUI can be launched with a single command:
17
+ ```bash
18
+ combaero-gui
19
+ ```
20
+ This starts both the FastAPI backend and the Vite-based frontend.
21
+
22
+ ## Key Workflows
23
+
24
+ - **Build**: Drag nodes (Plenums, Boundaries) and elements (Orifices, Channels) from the sidebar.
25
+ - **Connect**: Click and drag from node handles to element ports.
26
+ - **Solve**: Click the **Solve** button in the header. Successful solves turn node borders green.
27
+ - **Inspect**: Click on any node or element to view real-time diagnostics in the Inspector sidebar.
28
+
29
+ ---
30
+
31
+ ## Agent Reference (UI Automation Map)
32
+
33
+ For agents with screen control (multimodal LLMs), the following selectors and IDs are provided to assist in navigation:
34
+
35
+ | UI Component | Selector / ID | Description |
36
+ | :--- | :--- | :--- |
37
+ | **Solve Button** | `#solve-btn` | Triggers the network solve. |
38
+ | **Inspector Sidebar** | `.inspector-panel` | Properties for selected node/element. |
39
+ | **Network Canvas** | `#rf-canvas` | The main React Flow workspace. |
40
+ | **Node (General)** | `.react-flow__node` | Any node on the canvas. |
41
+ | **Solved Indicator** | `.solved-status-ok` | Appears on nodes after a successful solve. |
42
+ | **Error Console** | `#error-output` | Displays solver convergence failures. |
43
+
44
+ > [!TIP]
45
+ > **Automation Hint**: To add a node via script, use the `gui.backend` API or generate a network JSON and load it via `File -> Import`.
46
+
47
+ ## Technical Reference
48
+ For internal data structures and JSON schemas, see [docs/GUI_TECHNICAL.md](../docs/GUI_TECHNICAL.md).
File without changes
File without changes
@@ -0,0 +1,443 @@
1
+ from combaero.network import (
2
+ AreaChangeElement,
3
+ ChannelElement,
4
+ CombustorNode,
5
+ ConstantFractionLoss,
6
+ ConstantHeadLoss,
7
+ ConvectiveSurface,
8
+ DimpledModel,
9
+ FlowNetwork,
10
+ ImpingementModel,
11
+ LinearThetaFractionLoss,
12
+ LinearThetaHeadLoss,
13
+ LosslessConnectionElement,
14
+ MassFlowBoundary,
15
+ MomentumChamberNode,
16
+ OrificeElement,
17
+ PinFinModel,
18
+ PlenumNode,
19
+ PressureBoundary,
20
+ PressureLossElement,
21
+ RibbedModel,
22
+ SmoothModel,
23
+ ThermalWall,
24
+ WallLayer,
25
+ )
26
+
27
+ from .schemas import (
28
+ AreaChangeData,
29
+ ChannelData,
30
+ CombustorData,
31
+ CompositionData,
32
+ ConstantFractionLossData,
33
+ ConstantHeadLossData,
34
+ DimpledModelData,
35
+ DiscreteLossData,
36
+ ImpingementModelData,
37
+ LinearThetaFractionLossData,
38
+ LinearThetaHeadLossData,
39
+ MassBoundaryData,
40
+ MomentumChamberData,
41
+ NetworkGraphSchema,
42
+ OrificeData,
43
+ PinFinModelData,
44
+ PlenumData,
45
+ PressureBoundaryData,
46
+ RibbedModelData,
47
+ SmoothModelData,
48
+ ThermalWallData,
49
+ )
50
+
51
+
52
+ def build_pressure_loss(data, area: float = 0.1):
53
+ """Converts PressureLossData schema to a pressure-loss callable."""
54
+ if data is None:
55
+ return None
56
+ t = getattr(data, "type", None)
57
+ if isinstance(data, ConstantFractionLossData) or t == "constant_fraction":
58
+ return ConstantFractionLoss(xi=data.xi)
59
+ if isinstance(data, LinearThetaFractionLossData) or t == "linear_theta_fraction":
60
+ return LinearThetaFractionLoss(k=data.k, xi0=data.xi0)
61
+ if isinstance(data, ConstantHeadLossData) or t == "constant_head":
62
+ return ConstantHeadLoss(zeta=data.zeta, area=area)
63
+ if isinstance(data, LinearThetaHeadLossData) or t == "linear_theta_head":
64
+ return LinearThetaHeadLoss(k=data.k, zeta0=data.zeta0, area=area)
65
+ return None
66
+
67
+
68
+ def map_surface_model(data):
69
+ """Maps UI SurfaceModelData to combaero.network models."""
70
+ if isinstance(data, SmoothModelData) or data.type == "smooth":
71
+ return SmoothModel()
72
+ elif isinstance(data, RibbedModelData) or data.type == "ribbed":
73
+ return RibbedModel(
74
+ e_D=data.e_D,
75
+ pitch_to_height=data.pitch_to_height,
76
+ alpha_deg=data.alpha_deg,
77
+ )
78
+ elif isinstance(data, DimpledModelData) or data.type == "dimpled":
79
+ return DimpledModel(
80
+ d_Dh=data.d_Dh,
81
+ h_d=data.h_d,
82
+ S_d=data.S_d,
83
+ )
84
+ elif isinstance(data, PinFinModelData) or data.type == "pin_fin":
85
+ return PinFinModel(
86
+ pin_diameter=data.pin_diameter,
87
+ channel_height=data.channel_height,
88
+ S_D=data.S_D,
89
+ X_D=data.X_D,
90
+ N_rows=data.N_rows,
91
+ is_staggered=data.is_staggered,
92
+ )
93
+ elif isinstance(data, ImpingementModelData) or data.type == "impingement":
94
+ return ImpingementModel(
95
+ d_jet=data.d_jet,
96
+ z_D=data.z_D,
97
+ x_D=data.x_D,
98
+ y_D=data.y_D,
99
+ A_target=data.A_target,
100
+ Cd_jet=data.Cd_jet,
101
+ )
102
+ return SmoothModel()
103
+
104
+
105
+ def resolve_composition(comp: CompositionData, T: float, P: float) -> list[float]:
106
+ """
107
+ Converts UI CompositionData into a mass fraction list (Y)
108
+ using combaero.species utilities.
109
+ """
110
+ import combaero as cb
111
+
112
+ if comp.source == "dry_air":
113
+ Y = cb.species.dry_air_mass()
114
+ elif comp.source == "humid_air":
115
+ # humid_air_mass takes ambient T, P, RH
116
+ Y = cb.species.humid_air_mass(comp.ambient_T, comp.ambient_P, comp.relative_humidity)
117
+ elif comp.source == "fuel":
118
+ # Default fuel to CH4 for now, or use a specific fuel if provided
119
+ Y = cb.species.pure_species("CH4")
120
+ Y = cb.species.to_mass(Y)
121
+ elif comp.source == "custom" and comp.custom_fractions:
122
+ vec = cb.species.from_mapping(comp.custom_fractions)
123
+ Y = cb.species.to_mass(vec) if comp.mode == "mole" else vec
124
+ else:
125
+ Y = cb.species.dry_air_mass()
126
+
127
+ return [float(y) for y in Y]
128
+
129
+
130
+ def _expand_initial_guess(short_guess: dict, prefix: str) -> dict:
131
+ """Translate short GUI guess keys (``P``, ``T``, ``m_dot``) into the fully
132
+ qualified solver unknown names (e.g. ``"plenum1.P"``). ``P`` and ``T`` are
133
+ broadcast to both static and total variants because the solver exposes
134
+ them as separate unknowns on some node types; the solver silently ignores
135
+ entries that do not correspond to an actual unknown.
136
+ Any key already containing a ``.`` is passed through unchanged so that
137
+ hand-authored qualified guesses still work. Explicit short keys
138
+ (e.g. ``Pt``) take precedence over broadcast values from ``P``/``T``.
139
+ """
140
+ if not short_guess:
141
+ return {}
142
+ result: dict = {}
143
+ # Pass 1 — broadcast P and T to both static and total variants.
144
+ for key, val in short_guess.items():
145
+ if "." in key:
146
+ continue
147
+ if key == "P":
148
+ result[f"{prefix}.P"] = val
149
+ result[f"{prefix}.Pt"] = val
150
+ elif key == "T":
151
+ result[f"{prefix}.T"] = val
152
+ result[f"{prefix}.Tt"] = val
153
+ # Pass 2 — explicit short keys (Pt, Tt, m_dot, X[i], etc.)
154
+ # are applied after the broadcast so they win on conflict.
155
+ for key, val in short_guess.items():
156
+ if "." in key:
157
+ result[key] = val
158
+ continue
159
+ if key in ("P", "T"):
160
+ continue # already handled in pass 1
161
+ result[f"{prefix}.{key}"] = val
162
+ return result
163
+
164
+
165
+ def _build_discrete_loss_correlation(
166
+ corr_type: str,
167
+ xi: float,
168
+ k: float,
169
+ xi0: float,
170
+ zeta: float,
171
+ zeta0: float,
172
+ area: float,
173
+ ):
174
+ """Build a pressure loss correlation object from discrete loss params."""
175
+ if corr_type == "constant_fraction":
176
+ return ConstantFractionLoss(xi=xi)
177
+ if corr_type == "linear_theta_fraction":
178
+ return LinearThetaFractionLoss(k=k, xi0=xi0)
179
+ if corr_type == "constant_head":
180
+ return ConstantHeadLoss(zeta=zeta, area=area)
181
+ if corr_type == "linear_theta_head":
182
+ return LinearThetaHeadLoss(k=k, zeta0=zeta0, area=area)
183
+ return ConstantFractionLoss(xi=xi)
184
+
185
+
186
+ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
187
+ net = FlowNetwork()
188
+ nodes_map = {} # ID -> Physical Node
189
+ plenum_node_ids: set[str] = set()
190
+ element_nodes = [] # List of node schemas that are actually elements
191
+
192
+ # 1. First Pass: Create Physical Nodes
193
+ for node_schema in schema.nodes:
194
+ node_id = node_schema.id
195
+ node_data = node_schema.data
196
+ node_type = node_schema.type
197
+
198
+ if node_type == "plenum":
199
+ data = PlenumData(**node_data)
200
+ node = PlenumNode(node_id)
201
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
202
+ net.add_node(node)
203
+ nodes_map[node_id] = node
204
+ plenum_node_ids.add(node_id)
205
+ elif node_type == "mass_boundary":
206
+ data = MassBoundaryData(**node_data)
207
+ Y = resolve_composition(data.composition, data.Tt, 101325.0)
208
+ node = MassFlowBoundary(node_id, m_dot=data.m_dot, Tt=data.Tt, Y=Y)
209
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
210
+ net.add_node(node)
211
+ nodes_map[node_id] = node
212
+ elif node_type == "pressure_boundary":
213
+ data = PressureBoundaryData(**node_data)
214
+ Y = resolve_composition(data.composition, data.Tt, data.Pt)
215
+ node = PressureBoundary(node_id, Pt=data.Pt, Tt=data.Tt, Y=Y)
216
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
217
+ net.add_node(node)
218
+ nodes_map[node_id] = node
219
+ elif node_type == "combustor":
220
+ data = CombustorData(**node_data)
221
+ node = CombustorNode(
222
+ node_id,
223
+ method=data.method,
224
+ area=data.area,
225
+ Dh=data.Dh,
226
+ surface=ConvectiveSurface(
227
+ area=data.area,
228
+ model=map_surface_model(data.surface),
229
+ Nu_multiplier=data.Nu_multiplier,
230
+ f_multiplier=data.f_multiplier,
231
+ ),
232
+ )
233
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
234
+ net.add_node(node)
235
+ nodes_map[node_id] = node
236
+ elif node_type == "momentum_chamber":
237
+ data = MomentumChamberData(**node_data)
238
+ node = MomentumChamberNode(
239
+ node_id,
240
+ area=data.area,
241
+ surface=ConvectiveSurface(
242
+ area=data.area,
243
+ model=map_surface_model(data.surface),
244
+ Nu_multiplier=data.Nu_multiplier,
245
+ f_multiplier=data.f_multiplier,
246
+ ),
247
+ )
248
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
249
+ net.add_node(node)
250
+ nodes_map[node_id] = node
251
+ elif node_type == "probe":
252
+ pass # Display-only diagnostic nodes -- not part of the solver graph
253
+ else:
254
+ # This is an element node (Channel, Orifice, etc.)
255
+ element_nodes.append(node_schema)
256
+
257
+ element_ids = {node.id for node in element_nodes}
258
+
259
+ # 1b. Create implicit junction nodes for element -> element visual links.
260
+ edge_junction_map: dict[tuple[str, str], str] = {}
261
+ for edge in schema.edges:
262
+ if edge.data and edge.data.get("type") == "thermal":
263
+ continue
264
+ if edge.source in element_ids and edge.target in element_ids:
265
+ junction_id = f"__junction__{edge.source}__{edge.target}"
266
+ if junction_id not in nodes_map:
267
+ junction_node = MomentumChamberNode(junction_id)
268
+ net.add_node(junction_node)
269
+ nodes_map[junction_id] = junction_node
270
+ edge_junction_map[(edge.source, edge.target)] = junction_id
271
+
272
+ # 2. Second Pass: Create Elements
273
+ for elem_node_schema in element_nodes:
274
+ elem_id = elem_node_schema.id
275
+ elem_data = elem_node_schema.data
276
+
277
+ source_id = None
278
+ target_id = None
279
+ incoming_count = 0
280
+ outgoing_count = 0
281
+
282
+ for edge in schema.edges:
283
+ if edge.data and edge.data.get("type") == "thermal":
284
+ continue
285
+ if edge.target == elem_id:
286
+ incoming_count += 1
287
+ if incoming_count > 1:
288
+ raise ValueError(f"Element '{elem_id}' has multiple upstream links.")
289
+ if edge.source in nodes_map:
290
+ source_id = edge.source
291
+ elif edge.source in element_ids:
292
+ source_id = edge_junction_map.get((edge.source, elem_id))
293
+ if edge.source == elem_id:
294
+ outgoing_count += 1
295
+ if outgoing_count > 1:
296
+ raise ValueError(f"Element '{elem_id}' has multiple downstream links.")
297
+ if edge.target in nodes_map:
298
+ target_id = edge.target
299
+ elif edge.target in element_ids:
300
+ target_id = edge_junction_map.get((elem_id, edge.target))
301
+
302
+ if not source_id or not target_id:
303
+ raise ValueError(f"Element '{elem_id}' is not fully connected.")
304
+
305
+ elem_type = elem_node_schema.type
306
+ if elem_type == "channel":
307
+ data = ChannelData(**elem_data)
308
+ conv_area = 3.1415926535 * data.D * data.L
309
+ elem = ChannelElement(
310
+ elem_id,
311
+ from_node=source_id,
312
+ to_node=target_id,
313
+ length=data.L,
314
+ diameter=data.D,
315
+ roughness=data.roughness,
316
+ surface=ConvectiveSurface(
317
+ area=conv_area,
318
+ model=map_surface_model(data.surface),
319
+ Nu_multiplier=data.Nu_multiplier,
320
+ f_multiplier=data.f_multiplier,
321
+ ),
322
+ )
323
+ regime = (
324
+ data.regime if data.regime != "default" else schema.solver_settings.global_regime
325
+ )
326
+ elem.regime = regime
327
+ elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
328
+ net.add_element(elem)
329
+ elif elem_type == "orifice":
330
+ data = OrificeData(**elem_data)
331
+ elem = OrificeElement(
332
+ elem_id,
333
+ from_node=source_id,
334
+ to_node=target_id,
335
+ diameter=data.diameter,
336
+ Cd=data.Cd,
337
+ correlation=data.correlation,
338
+ plate_thickness=data.plate_thickness,
339
+ edge_radius=data.edge_radius,
340
+ )
341
+ regime = (
342
+ data.regime if data.regime != "default" else schema.solver_settings.global_regime
343
+ )
344
+ elem.regime = regime
345
+ elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
346
+ net.add_element(elem)
347
+ elif elem_type == "area_change":
348
+ data = AreaChangeData(**elem_data)
349
+ elem = AreaChangeElement(
350
+ id=elem_id,
351
+ from_node=source_id,
352
+ to_node=target_id,
353
+ F0=data.F0,
354
+ F1=data.F1,
355
+ model_type=data.model_type,
356
+ length=data.length,
357
+ D_h=data.D_h,
358
+ )
359
+ elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
360
+ net.add_element(elem)
361
+ elif elem_type == "lossless_connection":
362
+ elem = LosslessConnectionElement(elem_id, from_node=source_id, to_node=target_id)
363
+ net.add_element(elem)
364
+ elif elem_type == "discrete_loss":
365
+ data = DiscreteLossData(**elem_data)
366
+ # Infer area from upstream node if not explicitly set
367
+ area = data.area
368
+ if area is None and source_id in nodes_map:
369
+ area = getattr(nodes_map[source_id], "area", 0.1)
370
+ area = area if area and area > 0 else 0.1
371
+ correlation = _build_discrete_loss_correlation(
372
+ data.correlation_type, data.xi, data.k, data.xi0, data.zeta, data.zeta0, area
373
+ )
374
+ theta_source = data.theta_source if data.theta_source not in (None, "none") else None
375
+ surface = ConvectiveSurface(
376
+ area=area,
377
+ model=map_surface_model(data.surface),
378
+ Nu_multiplier=data.Nu_multiplier,
379
+ f_multiplier=data.f_multiplier,
380
+ )
381
+ elem = PressureLossElement(
382
+ elem_id,
383
+ from_node=source_id,
384
+ to_node=target_id,
385
+ correlation=correlation,
386
+ theta_source=theta_source,
387
+ area=area,
388
+ surface=surface,
389
+ )
390
+ elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
391
+ net.add_element(elem)
392
+
393
+ # 3. Third Pass: Auto-connections
394
+ for edge in schema.edges:
395
+ if (
396
+ edge.source in nodes_map
397
+ and edge.target in nodes_map
398
+ and not (edge.data or {}).get("type")
399
+ ):
400
+ auto_id = f"__auto_link__{edge.source}__{edge.target}"
401
+ if auto_id not in net.elements:
402
+ elem = LosslessConnectionElement(
403
+ auto_id, from_node=edge.source, to_node=edge.target
404
+ )
405
+ net.add_element(elem)
406
+
407
+ # 4. Fourth Pass: Thermal Walls
408
+ for edge in schema.edges:
409
+ if edge.data and edge.data.get("type") == "thermal":
410
+ if edge.source in plenum_node_ids or edge.target in plenum_node_ids:
411
+ continue
412
+ wall_id = edge.id
413
+ data = ThermalWallData(**edge.data)
414
+
415
+ # Build layers list
416
+ layers = []
417
+ if data.layers:
418
+ # New multi-layer format
419
+ layers = [
420
+ WallLayer(
421
+ thickness=L.thickness, conductivity=L.conductivity, material=L.material
422
+ )
423
+ for L in data.layers
424
+ ]
425
+ else:
426
+ # Legacy scalar format
427
+ t = data.thickness if data.thickness is not None else 0.003
428
+ k = data.conductivity if data.conductivity is not None else 20.0
429
+ layers = [WallLayer(thickness=t, conductivity=k)]
430
+
431
+ net.add_wall(
432
+ ThermalWall(
433
+ id=wall_id,
434
+ element_a=edge.source,
435
+ element_b=edge.target,
436
+ layers=layers,
437
+ contact_area=data.area,
438
+ R_fouling=data.R_fouling,
439
+ )
440
+ )
441
+ net.thermal_coupling_enabled = True
442
+
443
+ return net