combaero-gui 0.2.3__tar.gz → 0.3.1.dev2__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 (85) hide show
  1. combaero_gui-0.3.1.dev2/MANIFEST.in +1 -0
  2. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/PKG-INFO +2 -2
  3. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/backend/graph_builder.py +221 -13
  4. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/backend/main.py +20 -168
  5. combaero_gui-0.3.1.dev2/backend/runner.py +654 -0
  6. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/backend/schemas.py +49 -5
  7. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/backend/units.py +7 -0
  8. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/PKG-INFO +2 -2
  9. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/SOURCES.txt +12 -2
  10. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/requires.txt +1 -1
  11. combaero_gui-0.3.1.dev2/frontend/dist/assets/index-BACdja6I.js +19 -0
  12. combaero_gui-0.3.1.dev2/frontend/dist/assets/index-mUR2pMTx.css +2 -0
  13. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/dist/index.html +2 -2
  14. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/package.json +18 -18
  15. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/pnpm-lock.yaml +518 -500
  16. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/CompositionEditor.tsx +28 -5
  17. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/FlowEdge.tsx +34 -3
  18. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/Inspector.tsx +408 -13
  19. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/LengthInput.tsx +7 -1
  20. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/NetworkCanvas.tsx +19 -10
  21. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/NumericInput.tsx +8 -1
  22. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/Sidebar.tsx +184 -0
  23. combaero_gui-0.3.1.dev2/frontend/src/components/SolverStatusBadge.tsx +71 -0
  24. combaero_gui-0.3.1.dev2/frontend/src/components/ThermalEdge.tsx +305 -0
  25. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/UnitInput.tsx +5 -2
  26. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/flowTypes.ts +4 -0
  27. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/AreaChangeNode.tsx +26 -1
  28. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/ChannelNode.tsx +26 -1
  29. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/CombustorNode.tsx +26 -1
  30. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/DiscreteLossNode.tsx +26 -1
  31. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/LosslessNode.tsx +12 -6
  32. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/MomentumChamberNode.tsx +26 -1
  33. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/OrificeNode.tsx +26 -1
  34. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/PlenumNode.tsx +27 -1
  35. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/ProbeNode.tsx +4 -3
  36. combaero_gui-0.3.1.dev2/frontend/src/components/nodes/TeeJunctionNode.tsx +136 -0
  37. combaero_gui-0.3.1.dev2/frontend/src/components/nodes/VortexNode.tsx +47 -0
  38. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/store/useStore.ts +25 -1
  39. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/utils/diagnostics.ts +7 -1
  40. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/utils/quantities.ts +11 -0
  41. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/utils/validation.ts +12 -0
  42. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/pyproject.toml +7 -2
  43. combaero_gui-0.2.3/frontend/dist/assets/index-C41dzTWj.js +0 -19
  44. combaero_gui-0.2.3/frontend/dist/assets/index-DNc5NTHP.css +0 -2
  45. combaero_gui-0.2.3/frontend/src/components/SolverStatusBadge.tsx +0 -54
  46. combaero_gui-0.2.3/frontend/src/components/ThermalEdge.tsx +0 -171
  47. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/README.md +0 -0
  48. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/__init__.py +0 -0
  49. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/backend/__init__.py +0 -0
  50. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/dependency_links.txt +0 -0
  51. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/entry_points.txt +0 -0
  52. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/combaero_gui.egg-info/top_level.txt +0 -0
  53. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/.gitignore +0 -0
  54. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/README.md +0 -0
  55. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/dist/favicon.svg +0 -0
  56. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/dist/icons.svg +0 -0
  57. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/eslint.config.js +0 -0
  58. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/index.html +0 -0
  59. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/postcss.config.js +0 -0
  60. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/public/favicon.svg +0 -0
  61. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/public/icons.svg +0 -0
  62. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/App.css +0 -0
  63. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/App.tsx +0 -0
  64. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/api.ts +0 -0
  65. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/assets/hero.png +0 -0
  66. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/assets/react.svg +0 -0
  67. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/assets/vite.svg +0 -0
  68. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/AreaInput.tsx +0 -0
  69. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/DiagnosticsPanel.tsx +0 -0
  70. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/NodeDiagRows.tsx +0 -0
  71. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/SurfaceEnhancementInspector.tsx +0 -0
  72. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/WallLayersEditor.tsx +0 -0
  73. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/MassBoundaryNode.tsx +0 -0
  74. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/components/nodes/PressureBoundaryNode.tsx +0 -0
  75. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/index.css +0 -0
  76. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/main.tsx +0 -0
  77. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/node-25-regression.test.ts +0 -0
  78. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/src/utils/nodeUtils.ts +0 -0
  79. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/tailwind.config.js +0 -0
  80. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/tsconfig.app.json +0 -0
  81. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/tsconfig.json +0 -0
  82. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/tsconfig.node.json +0 -0
  83. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/vite.config.ts +0 -0
  84. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/frontend/vitest.config.ts +0 -0
  85. {combaero_gui-0.2.3 → combaero_gui-0.3.1.dev2}/setup.cfg +0 -0
@@ -0,0 +1 @@
1
+ recursive-include frontend/dist *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combaero-gui
3
- Version: 0.2.3
3
+ Version: 0.3.1.dev2
4
4
  Summary: CombAero Network GUI — React/FastAPI desktop application
5
5
  Author-email: thiemom <2641939+thiemom@users.noreply.github.com>
6
6
  License: MIT
@@ -21,7 +21,7 @@ Requires-Dist: fastapi
21
21
  Requires-Dist: uvicorn
22
22
  Requires-Dist: pydantic
23
23
  Requires-Dist: pandas
24
- Requires-Dist: combaero~=0.2
24
+ Requires-Dist: combaero~=0.3
25
25
  Provides-Extra: gui
26
26
 
27
27
  # CombAero Network Designer GUI
@@ -1,3 +1,5 @@
1
+ import math as _math
2
+
1
3
  from combaero.network import (
2
4
  AreaChangeElement,
3
5
  ChannelElement,
@@ -20,7 +22,9 @@ from combaero.network import (
20
22
  PressureLossElement,
21
23
  RibbedModel,
22
24
  SmoothModel,
25
+ TeeJunctionElement,
23
26
  ThermalWall,
27
+ VortexElement,
24
28
  WallLayer,
25
29
  )
26
30
 
@@ -45,7 +49,9 @@ from .schemas import (
45
49
  PressureBoundaryData,
46
50
  RibbedModelData,
47
51
  SmoothModelData,
52
+ TeeJunctionData,
48
53
  ThermalWallData,
54
+ VortexData,
49
55
  )
50
56
 
51
57
 
@@ -102,7 +108,9 @@ def map_surface_model(data):
102
108
  return SmoothModel()
103
109
 
104
110
 
105
- def resolve_composition(comp: CompositionData, T: float, P: float) -> list[float]:
111
+ def resolve_composition(
112
+ comp: CompositionData, T: float, P: float, solver_settings=None
113
+ ) -> list[float]:
106
114
  """
107
115
  Converts UI CompositionData into a mass fraction list (Y)
108
116
  using combaero.species utilities.
@@ -112,8 +120,23 @@ def resolve_composition(comp: CompositionData, T: float, P: float) -> list[float
112
120
  if comp.source == "dry_air":
113
121
  Y = cb.species.dry_air_mass()
114
122
  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)
123
+ s = solver_settings
124
+ ambient_T = (
125
+ comp.ambient_T
126
+ if comp.ambient_T is not None
127
+ else (s.ambient_T if s is not None and s.ambient_T is not None else 288.15)
128
+ )
129
+ ambient_P = (
130
+ comp.ambient_P
131
+ if comp.ambient_P is not None
132
+ else (s.ambient_P if s is not None and s.ambient_P is not None else 101325.0)
133
+ )
134
+ ambient_RH = (
135
+ comp.relative_humidity
136
+ if comp.relative_humidity is not None
137
+ else (s.ambient_RH if s is not None and s.ambient_RH is not None else 0.6)
138
+ )
139
+ Y = cb.species.humid_air_mass(ambient_T, ambient_P, ambient_RH)
117
140
  elif comp.source == "fuel":
118
141
  # Default fuel to CH4 for now, or use a specific fuel if provided
119
142
  Y = cb.species.pure_species("CH4")
@@ -162,6 +185,35 @@ def _expand_initial_guess(short_guess: dict, prefix: str) -> dict:
162
185
  return result
163
186
 
164
187
 
188
+ def _guess_from_prior_result(node_data: dict, prefix: str) -> dict:
189
+ """Build an initial-guess dict from a prior solve result stored in node_data.
190
+
191
+ The GUI frontend writes the last solve result into node_data["result"].
192
+ When data.initial_guess is empty (no explicit user override), this
193
+ provides a warm-start from the previously found state so that repeated
194
+ solves on a difficult network benefit from accumulated Newton progress.
195
+ Works for both pressure-node results (state.P/T/Pt/Tt) and element
196
+ results (m_dot stored at the top level).
197
+ """
198
+ prior = node_data.get("result")
199
+ if not prior:
200
+ return {}
201
+ short: dict[str, float] = {}
202
+ state = prior.get("state")
203
+ if state:
204
+ if (p := state.get("P")) is not None:
205
+ short["P"] = float(p)
206
+ if (t := state.get("T")) is not None:
207
+ short["T"] = float(t)
208
+ if (pt := state.get("Pt")) is not None:
209
+ short["Pt"] = float(pt)
210
+ if (tt := state.get("Tt")) is not None:
211
+ short["Tt"] = float(tt)
212
+ if (m_dot := prior.get("m_dot")) is not None:
213
+ short["m_dot"] = float(m_dot)
214
+ return _expand_initial_guess(short, prefix)
215
+
216
+
165
217
  def _build_discrete_loss_correlation(
166
218
  corr_type: str,
167
219
  xi: float,
@@ -183,6 +235,61 @@ def _build_discrete_loss_correlation(
183
235
  return ConstantFractionLoss(xi=xi)
184
236
 
185
237
 
238
+ def _port_from_handle(handle: str | None) -> str | None:
239
+ """Extract port name from a tee handle string.
240
+
241
+ 'port-common-source' -> 'common', 'port-branch-target' -> 'branch', etc.
242
+ Returns None for non-tee handles.
243
+ """
244
+ if (
245
+ handle
246
+ and handle.startswith("port-")
247
+ and (handle.endswith("-source") or handle.endswith("-target"))
248
+ ):
249
+ return handle.split("-")[1]
250
+ return None
251
+
252
+
253
+ def _find_tee_port(
254
+ edges: list,
255
+ tee_id: str,
256
+ port: str,
257
+ nodes_map: dict,
258
+ tee_port_node_map: dict | None = None,
259
+ ) -> str:
260
+ """Return the physical-node ID connected to a named tee port.
261
+
262
+ Checks both edge directions: tee as source (sourceHandle == port-X-source)
263
+ and tee as target (targetHandle == port-X-target).
264
+ Falls back to *tee_port_node_map* for auto-created junction nodes when an
265
+ element is connected directly to a tee port without an explicit plenum.
266
+ """
267
+ src_handle = f"port-{port}-source"
268
+ tgt_handle = f"port-{port}-target"
269
+ for edge in edges:
270
+ if edge.data and edge.data.get("type") == "thermal":
271
+ continue
272
+ if edge.source == tee_id and edge.sourceHandle == src_handle:
273
+ nid = edge.target
274
+ elif edge.target == tee_id and edge.targetHandle == tgt_handle:
275
+ nid = edge.source
276
+ else:
277
+ continue
278
+ if nid in nodes_map:
279
+ return nid
280
+ if tee_port_node_map and (tee_id, port) in tee_port_node_map:
281
+ return tee_port_node_map[(tee_id, port)]
282
+ raise ValueError(
283
+ f"Tee '{tee_id}' port '{port}' connects to '{nid}', which is not a "
284
+ "physical node (plenum or boundary). All tee ports must connect directly "
285
+ "to plena or boundaries."
286
+ )
287
+ raise ValueError(
288
+ f"Tee '{tee_id}' port '{port}' is not connected. "
289
+ "All three tee ports (common, straight, branch) must be wired to a node."
290
+ )
291
+
292
+
186
293
  def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
187
294
  net = FlowNetwork()
188
295
  nodes_map = {} # ID -> Physical Node
@@ -199,19 +306,23 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
199
306
  data = PlenumData(**node_data)
200
307
  node = PlenumNode(node_id)
201
308
  node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
309
+ if not node.initial_guess:
310
+ node.initial_guess = _guess_from_prior_result(node_data, node_id)
202
311
  net.add_node(node)
203
312
  nodes_map[node_id] = node
204
313
  plenum_node_ids.add(node_id)
205
314
  elif node_type == "mass_boundary":
206
315
  data = MassBoundaryData(**node_data)
207
- Y = resolve_composition(data.composition, data.Tt, 101325.0)
316
+ Y = resolve_composition(data.composition, data.Tt, 101325.0, schema.solver_settings)
208
317
  node = MassFlowBoundary(node_id, m_dot=data.m_dot, Tt=data.Tt, Y=Y)
209
318
  node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
319
+ if not node.initial_guess:
320
+ node.initial_guess = _guess_from_prior_result(node_data, node_id)
210
321
  net.add_node(node)
211
322
  nodes_map[node_id] = node
212
323
  elif node_type == "pressure_boundary":
213
324
  data = PressureBoundaryData(**node_data)
214
- Y = resolve_composition(data.composition, data.Tt, data.Pt)
325
+ Y = resolve_composition(data.composition, data.Tt, data.Pt, schema.solver_settings)
215
326
  node = PressureBoundary(node_id, Pt=data.Pt, Tt=data.Tt, Y=Y)
216
327
  node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
217
328
  net.add_node(node)
@@ -226,11 +337,14 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
226
337
  surface=ConvectiveSurface(
227
338
  area=data.area,
228
339
  model=map_surface_model(data.surface),
229
- Nu_multiplier=data.Nu_multiplier,
230
- f_multiplier=data.f_multiplier,
340
+ Nu_multiplier=data.Nu_multiplier
341
+ * (schema.solver_settings.Nu_multiplier or 1.0),
342
+ f_multiplier=data.f_multiplier * (schema.solver_settings.f_multiplier or 1.0),
231
343
  ),
232
344
  )
233
345
  node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
346
+ if not node.initial_guess:
347
+ node.initial_guess = _guess_from_prior_result(node_data, node_id)
234
348
  net.add_node(node)
235
349
  nodes_map[node_id] = node
236
350
  elif node_type == "momentum_chamber":
@@ -241,11 +355,14 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
241
355
  surface=ConvectiveSurface(
242
356
  area=data.area,
243
357
  model=map_surface_model(data.surface),
244
- Nu_multiplier=data.Nu_multiplier,
245
- f_multiplier=data.f_multiplier,
358
+ Nu_multiplier=data.Nu_multiplier
359
+ * (schema.solver_settings.Nu_multiplier or 1.0),
360
+ f_multiplier=data.f_multiplier * (schema.solver_settings.f_multiplier or 1.0),
246
361
  ),
247
362
  )
248
363
  node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
364
+ if not node.initial_guess:
365
+ node.initial_guess = _guess_from_prior_result(node_data, node_id)
249
366
  net.add_node(node)
250
367
  nodes_map[node_id] = node
251
368
  elif node_type == "probe":
@@ -255,13 +372,24 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
255
372
  element_nodes.append(node_schema)
256
373
 
257
374
  element_ids = {node.id for node in element_nodes}
375
+ tee_ids = {node.id for node in element_nodes if node.type == "tee_junction"}
258
376
 
259
377
  # 1b. Create implicit junction nodes for element -> element visual links.
378
+ # For direct element<->element connections we auto-insert a MomentumChamberNode.
379
+ # For direct element<->tee-port connections we auto-insert a PlenumNode so that
380
+ # users don't need to manually place a plenum on every tee arm.
260
381
  edge_junction_map: dict[tuple[str, str], str] = {}
382
+ tee_port_node_map: dict[tuple[str, str], str] = {} # (tee_id, port) -> node_id
261
383
  for edge in schema.edges:
262
384
  if edge.data and edge.data.get("type") == "thermal":
263
385
  continue
264
- if edge.source in element_ids and edge.target in element_ids:
386
+ src_elem = edge.source in element_ids
387
+ tgt_elem = edge.target in element_ids
388
+ src_tee = edge.source in tee_ids
389
+ tgt_tee = edge.target in tee_ids
390
+
391
+ if src_elem and tgt_elem and not src_tee and not tgt_tee:
392
+ # Standard element-to-element: auto-insert MomentumChamberNode
265
393
  junction_id = f"__junction__{edge.source}__{edge.target}"
266
394
  if junction_id not in nodes_map:
267
395
  junction_node = MomentumChamberNode(junction_id)
@@ -269,10 +397,60 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
269
397
  nodes_map[junction_id] = junction_node
270
398
  edge_junction_map[(edge.source, edge.target)] = junction_id
271
399
 
400
+ elif src_tee and tgt_elem:
401
+ # Tee port → element (e.g. tee.common-source → channel): auto-insert PlenumNode
402
+ port = _port_from_handle(edge.sourceHandle)
403
+ if port and (edge.source, port) not in tee_port_node_map:
404
+ jid = f"__tee_jct__{edge.source}_{port}"
405
+ if jid not in nodes_map:
406
+ jn = PlenumNode(jid)
407
+ net.add_node(jn)
408
+ nodes_map[jid] = jn
409
+ tee_port_node_map[(edge.source, port)] = jid
410
+ edge_junction_map[(edge.source, edge.target)] = jid
411
+
412
+ elif src_elem and tgt_tee:
413
+ # Element → tee port (e.g. channel → tee.straight-target): auto-insert PlenumNode
414
+ port = _port_from_handle(edge.targetHandle)
415
+ if port and (edge.target, port) not in tee_port_node_map:
416
+ jid = f"__tee_jct__{edge.target}_{port}"
417
+ if jid not in nodes_map:
418
+ jn = PlenumNode(jid)
419
+ net.add_node(jn)
420
+ nodes_map[jid] = jn
421
+ tee_port_node_map[(edge.target, port)] = jid
422
+ edge_junction_map[(edge.source, edge.target)] = jid
423
+
272
424
  # 2. Second Pass: Create Elements
273
425
  for elem_node_schema in element_nodes:
274
426
  elem_id = elem_node_schema.id
275
427
  elem_data = elem_node_schema.data
428
+ elem_type = elem_node_schema.type
429
+
430
+ # 3-port element: bypass the 2-port source/target logic entirely.
431
+ if elem_type == "tee_junction":
432
+ _d = TeeJunctionData(**elem_data)
433
+ _tee = TeeJunctionElement(
434
+ id=elem_id,
435
+ common_node=_find_tee_port(
436
+ schema.edges, elem_id, "common", nodes_map, tee_port_node_map
437
+ ),
438
+ straight_node=_find_tee_port(
439
+ schema.edges, elem_id, "straight", nodes_map, tee_port_node_map
440
+ ),
441
+ branch_node=_find_tee_port(
442
+ schema.edges, elem_id, "branch", nodes_map, tee_port_node_map
443
+ ),
444
+ theta=_math.radians(_d.theta_deg),
445
+ F_C=_d.F_C,
446
+ psi=_d.psi,
447
+ tee_type=_d.tee_type,
448
+ )
449
+ _tee.initial_guess = _expand_initial_guess(_d.initial_guess, elem_id)
450
+ if not _tee.initial_guess:
451
+ _tee.initial_guess = _guess_from_prior_result(elem_data, elem_id)
452
+ net.add_element(_tee)
453
+ continue
276
454
 
277
455
  source_id = None
278
456
  target_id = None
@@ -302,7 +480,6 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
302
480
  if not source_id or not target_id:
303
481
  raise ValueError(f"Element '{elem_id}' is not fully connected.")
304
482
 
305
- elem_type = elem_node_schema.type
306
483
  if elem_type == "channel":
307
484
  data = ChannelData(**elem_data)
308
485
  conv_area = 3.1415926535 * data.D * data.L
@@ -313,11 +490,13 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
313
490
  length=data.L,
314
491
  diameter=data.D,
315
492
  roughness=data.roughness,
493
+ friction_model=data.friction_model,
316
494
  surface=ConvectiveSurface(
317
495
  area=conv_area,
318
496
  model=map_surface_model(data.surface),
319
- Nu_multiplier=data.Nu_multiplier,
320
- f_multiplier=data.f_multiplier,
497
+ Nu_multiplier=data.Nu_multiplier
498
+ * (schema.solver_settings.Nu_multiplier or 1.0),
499
+ f_multiplier=data.f_multiplier * (schema.solver_settings.f_multiplier or 1.0),
321
500
  ),
322
501
  )
323
502
  regime = (
@@ -325,6 +504,8 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
325
504
  )
326
505
  elem.regime = regime
327
506
  elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
507
+ if not elem.initial_guess:
508
+ elem.initial_guess = _guess_from_prior_result(elem_data, elem_id)
328
509
  net.add_element(elem)
329
510
  elif elem_type == "orifice":
330
511
  data = OrificeData(**elem_data)
@@ -343,6 +524,8 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
343
524
  )
344
525
  elem.regime = regime
345
526
  elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
527
+ if not elem.initial_guess:
528
+ elem.initial_guess = _guess_from_prior_result(elem_data, elem_id)
346
529
  net.add_element(elem)
347
530
  elif elem_type == "area_change":
348
531
  data = AreaChangeData(**elem_data)
@@ -357,6 +540,8 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
357
540
  D_h=data.D_h,
358
541
  )
359
542
  elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
543
+ if not elem.initial_guess:
544
+ elem.initial_guess = _guess_from_prior_result(elem_data, elem_id)
360
545
  net.add_element(elem)
361
546
  elif elem_type == "lossless_connection":
362
547
  elem = LosslessConnectionElement(elem_id, from_node=source_id, to_node=target_id)
@@ -388,6 +573,29 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
388
573
  surface=surface,
389
574
  )
390
575
  elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
576
+ if not elem.initial_guess:
577
+ elem.initial_guess = _guess_from_prior_result(elem_data, elem_id)
578
+ net.add_element(elem)
579
+ elif elem_type == "vortex":
580
+ data = VortexData(**elem_data)
581
+ omega = (
582
+ data.omega_rpm
583
+ if data.omega_rpm is not None
584
+ else (schema.solver_settings.omega_rpm or 0.0)
585
+ )
586
+ elem = VortexElement(
587
+ elem_id,
588
+ from_node=source_id,
589
+ to_node=target_id,
590
+ r_c=data.r_c,
591
+ r_out=data.r_out,
592
+ r_in=data.r_in,
593
+ omega_rpm=float(omega),
594
+ n=data.n,
595
+ )
596
+ elem.initial_guess = _expand_initial_guess(data.initial_guess, elem_id)
597
+ if not elem.initial_guess:
598
+ elem.initial_guess = _guess_from_prior_result(elem_data, elem_id)
391
599
  net.add_element(elem)
392
600
 
393
601
  # 3. Third Pass: Auto-connections
@@ -11,12 +11,10 @@ import combaero as cb
11
11
  from combaero.network import NetworkSolver
12
12
 
13
13
  from .graph_builder import build_network_from_schema
14
+ from .runner import NetworkResult, _build_result_objects, _schema_maps
14
15
  from .schemas import (
15
- ElementResult,
16
16
  NetworkGraphSchema,
17
- NodeResult,
18
17
  SolveResponse,
19
- StateResult,
20
18
  )
21
19
 
22
20
  _FRONTEND_DIST = Path(__file__).parent.parent / "frontend" / "dist"
@@ -129,62 +127,7 @@ def _solve_sync(schema: NetworkGraphSchema):
129
127
  "initial_guesses": {n.id: n.data.get("initial_guess", {}) for n in schema.nodes},
130
128
  }
131
129
 
132
- node_results = {}
133
- for node_id in net.nodes:
134
- # Pull all node diagnostics and state variables directly from sol_dict
135
- # flat keys are like node_id.T, node_id.P, node_id.mach, node_id.rho, etc.
136
- prefix = f"{node_id}."
137
- node_vals = {k[len(prefix) :]: v for k, v in result.items() if k.startswith(prefix)}
138
-
139
- # Handle composition lists (Y and X)
140
- y_vals = []
141
- x_vals = []
142
- i = 0
143
- while f"Y[{i}]" in node_vals:
144
- y_vals.append(float(node_vals.pop(f"Y[{i}]")))
145
- if f"X[{i}]" in node_vals:
146
- x_vals.append(float(node_vals.pop(f"X[{i}]")))
147
- i += 1
148
-
149
- state_res = StateResult(
150
- T=float(node_vals.pop("T", 300.0)),
151
- P=float(node_vals.pop("P", 101325.0)),
152
- Pt=node_vals.pop("Pt", None),
153
- Tt=node_vals.pop("Tt", None),
154
- Y=y_vals,
155
- X=x_vals or None,
156
- **node_vals, # All remaining diagnostics (rho, mach, mu, k, etc.)
157
- )
158
- node_results[node_id] = NodeResult(state=state_res, success=success)
159
-
160
- element_results = {}
161
- elem_diags = result.get("__element_diag__", {})
162
- for elem_id in net.elements:
163
- m_dot = float(result.get(f"{elem_id}.m_dot", 0.0))
164
- # Pull element diagnostics from the structured dict aggregated by solve()
165
- diag = elem_diags.get(elem_id, {}).copy()
166
- diag.pop("m_dot", None)
167
- element_results[elem_id] = ElementResult(m_dot=m_dot, success=success, **diag)
168
-
169
- edge_results = {}
170
-
171
- def _safe_float(v):
172
- try:
173
- if isinstance(v, (list, tuple, dict)):
174
- return v
175
- return float(v)
176
- except (TypeError, ValueError):
177
- return v
178
-
179
- for edge_id in net.walls:
180
- edge_keys = {
181
- k.split(".", 1)[1]: _safe_float(v)
182
- for k, v in result.items()
183
- if k.startswith(f"{edge_id}.")
184
- }
185
- if edge_keys:
186
- edge_results[edge_id] = edge_keys
187
-
130
+ node_results, element_results, edge_results = _build_result_objects(result, net)
188
131
  return result, node_results, element_results, edge_results, net
189
132
 
190
133
 
@@ -228,115 +171,24 @@ async def export_results(schema: NetworkGraphSchema):
228
171
  hard_timeout = soft_timeout + 10.0
229
172
  try:
230
173
  # Solve quickly to get states
231
- _, node_results, element_results, edge_results, _ = await asyncio.wait_for(
174
+ raw, node_results, element_results, edge_results, net = await asyncio.wait_for(
232
175
  asyncio.to_thread(_solve_sync, schema),
233
176
  timeout=hard_timeout,
234
177
  )
235
178
 
236
- # Convert to DataFrame
237
- import pandas as pd
238
-
239
- # Build unified id_to_label map from nodes, edges, and auto-links
240
- id_to_label = {}
241
- # Map each schema node/edge id to its declared ``type`` so the CSV can
242
- # preserve specific-kind information (plenum vs combustor vs
243
- # discrete_loss, …) needed for later round-trips or sweep workflows.
244
- id_to_kind: dict[str, str] = {}
245
- for n in schema.nodes:
246
- id_to_label[n.id] = n.data.get("label") or ""
247
- id_to_kind[n.id] = n.type
248
- for e in schema.edges:
249
- lbl = (e.data or {}).get("label") or ""
250
- id_to_label[e.id] = lbl
251
- id_to_kind[e.id] = (e.data or {}).get("type") or "flow"
252
- # Also map solver auto-link IDs
253
- auto_id = f"__auto_link__{e.source}__{e.target}"
254
- id_to_label[auto_id] = lbl
255
- id_to_kind[auto_id] = "lossless_connection"
256
-
257
- _BOUNDARY_KINDS = {"mass_boundary", "pressure_boundary"}
258
-
259
- # Per-node combustion method (empty string for non-combustor rows).
260
- id_to_method: dict[str, str] = {}
261
- for n in schema.nodes:
262
- if n.type == "combustor":
263
- id_to_method[n.id] = n.data.get("method", "complete")
264
-
265
- # Species labels for composition column headers (``Y[N2]`` not ``Y[0]``).
266
- import combaero as cb
267
-
268
- species_labels: list[str] = list(cb.species.names)
269
-
270
- data = []
271
-
272
- # 1. Process Nodes
273
- for node_id, res in node_results.items():
274
- # Include all fields from StateResult (rho, mach, k, etc.)
275
- state_data = res.state.model_dump()
276
- # Handle Y/X list expansion
277
- Y = state_data.pop("Y", [])
278
- X = state_data.pop("X", None)
279
-
280
- kind = id_to_kind.get(node_id, "")
281
- base_data = {
282
- "type": "node",
283
- "kind": kind,
284
- "is_boundary": kind in _BOUNDARY_KINDS,
285
- "combustion_method": id_to_method.get(node_id, ""),
286
- "id": node_id,
287
- "label": id_to_label.get(node_id, ""),
288
- **state_data,
289
- }
290
- # Flatten species fractions with species-name indexing.
291
- for i, y_val in enumerate(Y):
292
- name = species_labels[i] if i < len(species_labels) else str(i)
293
- base_data[f"Y[{name}]"] = y_val
294
- if X:
295
- for i, x_val in enumerate(X):
296
- name = species_labels[i] if i < len(species_labels) else str(i)
297
- base_data[f"X[{name}]"] = x_val
298
-
299
- data.append(base_data)
300
-
301
- # 2. Process Flow Elements
302
- for elem_id, res in element_results.items():
303
- # Include all analytic/diagnostic fields (Re, Nu, h, q_dot, f, etc.)
304
- elem_data = res.model_dump()
305
- data.append(
306
- {
307
- "type": "element",
308
- "kind": id_to_kind.get(elem_id, ""),
309
- "is_boundary": False,
310
- "combustion_method": "",
311
- "id": elem_id,
312
- "label": id_to_label.get(elem_id, ""),
313
- **elem_data,
314
- }
315
- )
316
-
317
- # 3. Process Thermal Walls (edge_results)
318
- for wall_id, res in edge_results.items():
319
- # thermal wall results are already a dict from _solve_sync
320
- data.append(
321
- {
322
- "type": "thermal_wall",
323
- "kind": "thermal_wall",
324
- "is_boundary": False,
325
- "combustion_method": "",
326
- "id": wall_id,
327
- "label": id_to_label.get(wall_id, ""),
328
- **res,
329
- }
330
- )
331
-
332
- df = pd.DataFrame(data)
333
-
334
- # Rename columns with unit annotations (e.g. ``P`` -> ``P [Pa]``).
335
- # Meta columns (``id``, ``type``, ``kind``, ``is_boundary``, ...) are
336
- # passed through unchanged; unknown columns also remain untouched.
337
- from gui.backend.units import label_with_unit
338
-
339
- df = df.rename(columns={col: label_with_unit(col) for col in df.columns})
179
+ id_to_label, id_to_kind, id_to_method = _schema_maps(schema)
180
+ result_obj = NetworkResult(
181
+ raw=raw,
182
+ node_results=node_results,
183
+ element_results=element_results,
184
+ edge_results=edge_results,
185
+ net=net,
186
+ schema=schema,
187
+ id_to_label=id_to_label,
188
+ id_to_kind=id_to_kind,
189
+ id_to_method=id_to_method,
190
+ )
191
+ df = result_obj.to_dataframe()
340
192
 
341
193
  # Stream as CSV
342
194
  stream = io.StringIO()
@@ -369,6 +221,10 @@ async def health():
369
221
  return {"status": "ok"}
370
222
 
371
223
 
224
+ if (_FRONTEND_DIST / "assets").is_dir():
225
+ app.mount("/assets", StaticFiles(directory=str(_FRONTEND_DIST / "assets")), name="assets")
226
+
227
+
372
228
  @app.get("/{path:path}")
373
229
  async def serve_spa_route(path: str):
374
230
  if _FRONTEND_DIST.is_dir():
@@ -376,10 +232,6 @@ async def serve_spa_route(path: str):
376
232
  raise HTTPException(status_code=404, detail=f"/{path} not found")
377
233
 
378
234
 
379
- if (_FRONTEND_DIST / "assets").is_dir():
380
- app.mount("/assets", StaticFiles(directory=str(_FRONTEND_DIST / "assets")), name="assets")
381
-
382
-
383
235
  def run_server(host: str = "127.0.0.1", port: int = 8000) -> None:
384
236
  import uvicorn
385
237