combaero-gui 0.3.1.dev2__tar.gz → 0.4.2__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 (94) hide show
  1. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/PKG-INFO +2 -2
  2. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/graph_builder.py +235 -40
  3. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/main.py +9 -3
  4. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/runner.py +0 -18
  5. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/schemas.py +63 -14
  6. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/PKG-INFO +2 -2
  7. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/SOURCES.txt +11 -5
  8. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/requires.txt +1 -1
  9. combaero_gui-0.4.2/combaero_gui.egg-info/scm_file_list.json +76 -0
  10. combaero_gui-0.4.2/combaero_gui.egg-info/scm_version.json +8 -0
  11. combaero_gui-0.4.2/frontend/dist/assets/index-ImDBQgh3.js +20 -0
  12. combaero_gui-0.4.2/frontend/dist/assets/index-hDCh9ZPl.css +2 -0
  13. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/dist/index.html +2 -2
  14. combaero_gui-0.4.2/frontend/package.json +44 -0
  15. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/pnpm-lock.yaml +557 -549
  16. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/api.ts +17 -0
  17. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/AreaInput.tsx +3 -1
  18. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/CompositionEditor.tsx +2 -0
  19. combaero_gui-0.4.2/frontend/src/components/ConvergenceChart.tsx +287 -0
  20. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/FlowEdge.tsx +3 -34
  21. combaero_gui-0.4.2/frontend/src/components/Inspector.tsx +3155 -0
  22. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/LengthInput.tsx +3 -1
  23. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/NetworkCanvas.tsx +11 -6
  24. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/NumericInput.tsx +6 -1
  25. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/Sidebar.tsx +32 -13
  26. combaero_gui-0.4.2/frontend/src/components/SolverStatusBadge.tsx +134 -0
  27. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/ThermalEdge.tsx +3 -33
  28. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/UnitInput.tsx +4 -1
  29. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/flowTypes.ts +4 -2
  30. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/AreaChangeNode.tsx +25 -4
  31. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/ChannelNode.tsx +25 -4
  32. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/CombustorNode.tsx +25 -4
  33. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/DiscreteLossNode.tsx +25 -4
  34. combaero_gui-0.4.2/frontend/src/components/nodes/MPCETeeNode.tsx +152 -0
  35. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/MassBoundaryNode.tsx +13 -2
  36. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/MomentumChamberNode.tsx +25 -4
  37. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/OrificeNode.tsx +25 -4
  38. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/PlenumNode.tsx +25 -4
  39. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/PressureBoundaryNode.tsx +13 -2
  40. combaero_gui-0.4.2/frontend/src/components/nodes/WallNode.tsx +59 -0
  41. combaero_gui-0.4.2/frontend/src/hooks/useCopyPaste.ts +83 -0
  42. combaero_gui-0.4.2/frontend/src/store/useStore.test.ts +158 -0
  43. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/store/useStore.ts +50 -11
  44. combaero_gui-0.4.2/frontend/src/utils/nodeUtils.ts +77 -0
  45. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/utils/quantities.ts +6 -0
  46. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/utils/validation.ts +21 -13
  47. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/pyproject.toml +1 -1
  48. combaero_gui-0.3.1.dev2/frontend/dist/assets/index-BACdja6I.js +0 -19
  49. combaero_gui-0.3.1.dev2/frontend/dist/assets/index-mUR2pMTx.css +0 -2
  50. combaero_gui-0.3.1.dev2/frontend/package.json +0 -44
  51. combaero_gui-0.3.1.dev2/frontend/src/components/Inspector.tsx +0 -2095
  52. combaero_gui-0.3.1.dev2/frontend/src/components/SolverStatusBadge.tsx +0 -71
  53. combaero_gui-0.3.1.dev2/frontend/src/components/nodes/TeeJunctionNode.tsx +0 -136
  54. combaero_gui-0.3.1.dev2/frontend/src/utils/nodeUtils.ts +0 -11
  55. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/MANIFEST.in +0 -0
  56. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/README.md +0 -0
  57. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/__init__.py +0 -0
  58. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/__init__.py +0 -0
  59. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/backend/units.py +0 -0
  60. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/dependency_links.txt +0 -0
  61. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/entry_points.txt +0 -0
  62. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/combaero_gui.egg-info/top_level.txt +0 -0
  63. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/.gitignore +0 -0
  64. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/README.md +0 -0
  65. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/dist/favicon.svg +0 -0
  66. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/dist/icons.svg +0 -0
  67. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/eslint.config.js +0 -0
  68. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/index.html +0 -0
  69. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/postcss.config.js +0 -0
  70. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/public/favicon.svg +0 -0
  71. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/public/icons.svg +0 -0
  72. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/App.css +0 -0
  73. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/App.tsx +0 -0
  74. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/assets/hero.png +0 -0
  75. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/assets/react.svg +0 -0
  76. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/assets/vite.svg +0 -0
  77. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/DiagnosticsPanel.tsx +0 -0
  78. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/NodeDiagRows.tsx +0 -0
  79. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/SurfaceEnhancementInspector.tsx +0 -0
  80. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/WallLayersEditor.tsx +0 -0
  81. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/LosslessNode.tsx +0 -0
  82. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/ProbeNode.tsx +0 -0
  83. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/components/nodes/VortexNode.tsx +0 -0
  84. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/index.css +0 -0
  85. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/main.tsx +0 -0
  86. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/node-25-regression.test.ts +0 -0
  87. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/src/utils/diagnostics.ts +0 -0
  88. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/tailwind.config.js +0 -0
  89. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/tsconfig.app.json +0 -0
  90. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/tsconfig.json +0 -0
  91. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/tsconfig.node.json +0 -0
  92. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/vite.config.ts +0 -0
  93. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/frontend/vitest.config.ts +0 -0
  94. {combaero_gui-0.3.1.dev2 → combaero_gui-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combaero-gui
3
- Version: 0.3.1.dev2
3
+ Version: 0.4.2
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.3
24
+ Requires-Dist: combaero~=0.4
25
25
  Provides-Extra: gui
26
26
 
27
27
  # CombAero Network Designer GUI
@@ -22,11 +22,12 @@ from combaero.network import (
22
22
  PressureLossElement,
23
23
  RibbedModel,
24
24
  SmoothModel,
25
- TeeJunctionElement,
26
25
  ThermalWall,
27
26
  VortexElement,
28
27
  WallLayer,
28
+ WallNode,
29
29
  )
30
+ from combaero.network.mpce_v2_element import ConstantKTeeElement, MPCEv2Element
30
31
 
31
32
  from .schemas import (
32
33
  AreaChangeData,
@@ -42,6 +43,7 @@ from .schemas import (
42
43
  LinearThetaHeadLossData,
43
44
  MassBoundaryData,
44
45
  MomentumChamberData,
46
+ MPCETeeData,
45
47
  NetworkGraphSchema,
46
48
  OrificeData,
47
49
  PinFinModelData,
@@ -49,9 +51,9 @@ from .schemas import (
49
51
  PressureBoundaryData,
50
52
  RibbedModelData,
51
53
  SmoothModelData,
52
- TeeJunctionData,
53
54
  ThermalWallData,
54
55
  VortexData,
56
+ WallData,
55
57
  )
56
58
 
57
59
 
@@ -194,9 +196,14 @@ def _guess_from_prior_result(node_data: dict, prefix: str) -> dict:
194
196
  solves on a difficult network benefit from accumulated Newton progress.
195
197
  Works for both pressure-node results (state.P/T/Pt/Tt) and element
196
198
  results (m_dot stored at the top level).
199
+
200
+ Only CONVERGED prior results are used: the best iterate of a failed
201
+ solve is stored too, and seeding from it parks Newton at the stall
202
+ point -- one failed solve (e.g. a timeout) would otherwise poison
203
+ every subsequent run of a network that converges fine from cold.
197
204
  """
198
205
  prior = node_data.get("result")
199
- if not prior:
206
+ if not prior or not prior.get("success"):
200
207
  return {}
201
208
  short: dict[str, float] = {}
202
209
  state = prior.get("state")
@@ -235,6 +242,93 @@ def _build_discrete_loss_correlation(
235
242
  return ConstantFractionLoss(xi=xi)
236
243
 
237
244
 
245
+ def _channel_D_at_tee_port(
246
+ schema_edges,
247
+ schema_nodes,
248
+ tee_id: str,
249
+ port: str,
250
+ ) -> float | None:
251
+ """Return the inner diameter [m] of a channel directly connected to a tee port.
252
+
253
+ The user-facing schema connects channels straight to tee handles (the
254
+ port-MCN is auto-inserted at network-build time, not at schema level).
255
+ Used by ``mpce_tee`` dispatch to inherit ``F_C`` / ``F_branch``
256
+ from the connected channel geometry when the
257
+ user has not explicitly set them. Returns None when no channel is
258
+ directly connected (e.g., plenum, boundary, multi-hop).
259
+ """
260
+ src_handle = f"port-{port}-source"
261
+ tgt_handle = f"port-{port}-target"
262
+ for edge in schema_edges:
263
+ if edge.data and edge.data.get("type") == "thermal":
264
+ continue
265
+ neighbour_id: str | None = None
266
+ if edge.source == tee_id and edge.sourceHandle == src_handle:
267
+ neighbour_id = edge.target
268
+ elif edge.target == tee_id and edge.targetHandle == tgt_handle:
269
+ neighbour_id = edge.source
270
+ if neighbour_id is None:
271
+ continue
272
+ neighbour = next((n for n in schema_nodes if n.id == neighbour_id), None)
273
+ if neighbour is None or neighbour.type != "channel":
274
+ continue
275
+ d = neighbour.data.get("D") if isinstance(neighbour.data, dict) else None
276
+ if d is None:
277
+ continue
278
+ try:
279
+ d_val = float(d)
280
+ except (TypeError, ValueError):
281
+ continue
282
+ if d_val > 0.0:
283
+ return d_val
284
+ return None
285
+
286
+
287
+ def _resolve_tee_areas(
288
+ schema_edges,
289
+ schema_nodes,
290
+ tee_id: str,
291
+ F_C_explicit: float | None,
292
+ F_branch_explicit: float | None,
293
+ psi_fallback: float,
294
+ ) -> tuple[float, float, float]:
295
+ """Resolve (F_C, F_branch, psi) for a 3-port tee, applying inheritance.
296
+
297
+ Precedence:
298
+ 1. Explicit user-set value (non-null) wins.
299
+ 2. Channel D at the connected port (auto-inherited).
300
+ 3. Fallback: F_C=0.01 m^2, F_branch derived from psi_fallback.
301
+
302
+ Returns (F_C, F_branch, psi) all resolved to concrete floats.
303
+ """
304
+ F_C = F_C_explicit
305
+ if F_C is None:
306
+ # Try common port first, then straight (both are F_C in Bassett/Idelchik
307
+ # geometry where F_s = F_c by definition).
308
+ for port in ("common", "straight"):
309
+ d = _channel_D_at_tee_port(schema_edges, schema_nodes, tee_id, port)
310
+ if d is not None:
311
+ F_C = _math.pi / 4.0 * d * d
312
+ break
313
+ if F_C is None:
314
+ F_C = 0.01 # ultimate default for a fully disconnected tee
315
+
316
+ F_branch = F_branch_explicit
317
+ if F_branch is None:
318
+ d = _channel_D_at_tee_port(schema_edges, schema_nodes, tee_id, "branch")
319
+ if d is not None:
320
+ F_branch = _math.pi / 4.0 * d * d
321
+
322
+ if F_branch is not None and F_branch > 0.0:
323
+ psi = F_C / F_branch
324
+ else:
325
+ # No branch channel; use stored psi to derive F_branch
326
+ psi = max(float(psi_fallback), 1e-12)
327
+ F_branch = F_C / psi
328
+
329
+ return F_C, F_branch, psi
330
+
331
+
238
332
  def _port_from_handle(handle: str | None) -> str | None:
239
333
  """Extract port name from a tee handle string.
240
334
 
@@ -256,6 +350,7 @@ def _find_tee_port(
256
350
  port: str,
257
351
  nodes_map: dict,
258
352
  tee_port_node_map: dict | None = None,
353
+ wall_edge_remap: dict | None = None,
259
354
  ) -> str:
260
355
  """Return the physical-node ID connected to a named tee port.
261
356
 
@@ -276,6 +371,8 @@ def _find_tee_port(
276
371
  else:
277
372
  continue
278
373
  if nid in nodes_map:
374
+ if wall_edge_remap and edge.id in wall_edge_remap:
375
+ return wall_edge_remap[edge.id]
279
376
  return nid
280
377
  if tee_port_node_map and (tee_id, port) in tee_port_node_map:
281
378
  return tee_port_node_map[(tee_id, port)]
@@ -335,7 +432,7 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
335
432
  area=data.area,
336
433
  Dh=data.Dh,
337
434
  surface=ConvectiveSurface(
338
- area=data.area,
435
+ area=data.area or 0.0,
339
436
  model=map_surface_model(data.surface),
340
437
  Nu_multiplier=data.Nu_multiplier
341
438
  * (schema.solver_settings.Nu_multiplier or 1.0),
@@ -352,8 +449,9 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
352
449
  node = MomentumChamberNode(
353
450
  node_id,
354
451
  area=data.area,
452
+ Dh=data.Dh,
355
453
  surface=ConvectiveSurface(
356
- area=data.area,
454
+ area=data.area or 0.0,
357
455
  model=map_surface_model(data.surface),
358
456
  Nu_multiplier=data.Nu_multiplier
359
457
  * (schema.solver_settings.Nu_multiplier or 1.0),
@@ -367,12 +465,18 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
367
465
  nodes_map[node_id] = node
368
466
  elif node_type == "probe":
369
467
  pass # Display-only diagnostic nodes -- not part of the solver graph
468
+ elif node_type == "wall":
469
+ data = WallData(**node_data)
470
+ node = WallNode(node_id)
471
+ node.initial_guess = _expand_initial_guess(data.initial_guess, node_id)
472
+ net.add_node(node)
473
+ nodes_map[node_id] = node
370
474
  else:
371
475
  # This is an element node (Channel, Orifice, etc.)
372
476
  element_nodes.append(node_schema)
373
477
 
374
478
  element_ids = {node.id for node in element_nodes}
375
- tee_ids = {node.id for node in element_nodes if node.type == "tee_junction"}
479
+ tee_ids = {node.id for node in element_nodes if node.type == "mpce_tee"}
376
480
 
377
481
  # 1b. Create implicit junction nodes for element -> element visual links.
378
482
  # For direct element<->element connections we auto-insert a MomentumChamberNode.
@@ -398,58 +502,138 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
398
502
  edge_junction_map[(edge.source, edge.target)] = junction_id
399
503
 
400
504
  elif src_tee and tgt_elem:
401
- # Tee port → element (e.g. tee.common-source → channel): auto-insert PlenumNode
505
+ # Tee port → element (e.g. tee.common-source → channel): auto-insert MomentumChamberNode
506
+ # so that Pt != P_static and velocity is correctly accounted for.
402
507
  port = _port_from_handle(edge.sourceHandle)
403
508
  if port and (edge.source, port) not in tee_port_node_map:
404
509
  jid = f"__tee_jct__{edge.source}_{port}"
405
510
  if jid not in nodes_map:
406
- jn = PlenumNode(jid)
511
+ jn = MomentumChamberNode(jid)
407
512
  net.add_node(jn)
408
513
  nodes_map[jid] = jn
409
514
  tee_port_node_map[(edge.source, port)] = jid
410
515
  edge_junction_map[(edge.source, edge.target)] = jid
411
516
 
412
517
  elif src_elem and tgt_tee:
413
- # Element → tee port (e.g. channel → tee.straight-target): auto-insert PlenumNode
518
+ # Element → tee port (e.g. channel → tee.straight-target): auto-insert MomentumChamberNode
414
519
  port = _port_from_handle(edge.targetHandle)
415
520
  if port and (edge.target, port) not in tee_port_node_map:
416
521
  jid = f"__tee_jct__{edge.target}_{port}"
417
522
  if jid not in nodes_map:
418
- jn = PlenumNode(jid)
523
+ jn = MomentumChamberNode(jid)
419
524
  net.add_node(jn)
420
525
  nodes_map[jid] = jn
421
526
  tee_port_node_map[(edge.target, port)] = jid
422
527
  edge_junction_map[(edge.source, edge.target)] = jid
423
528
 
529
+ # Pre-scan: for each edge whose target is a WallNode, the 2nd+ connection
530
+ # gets its own clone so the solver enforces m_dot=0 per branch individually
531
+ # (not just sum(m_dot)=0, which allows non-zero flows to cancel).
532
+ # This covers both regular elements and tee junction port connections.
533
+ _wall_edge_remap: dict[str, str] = {} # edge_id -> effective target node_id
534
+ _wall_target_count: dict[str, int] = {}
535
+ for _edge in schema.edges:
536
+ if _edge.data and _edge.data.get("type") == "thermal":
537
+ continue
538
+ _tgt = _edge.target
539
+ if isinstance(nodes_map.get(_tgt), WallNode):
540
+ _wall_target_count[_tgt] = _wall_target_count.get(_tgt, 0) + 1
541
+ if _wall_target_count[_tgt] > 1:
542
+ _n = _wall_target_count[_tgt]
543
+ _clone_id = f"{_tgt}_w{_n}"
544
+ _clone = WallNode(_clone_id)
545
+ net.add_node(_clone)
546
+ nodes_map[_clone_id] = _clone
547
+ _wall_edge_remap[_edge.id] = _clone_id
548
+
424
549
  # 2. Second Pass: Create Elements
425
550
  for elem_node_schema in element_nodes:
426
551
  elem_id = elem_node_schema.id
427
552
  elem_data = elem_node_schema.data
428
553
  elem_type = elem_node_schema.type
429
554
 
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,
555
+ # 3-port element (Mynard-based). The flow_direction field constrains
556
+ # which side is upstream.
557
+ if elem_type == "mpce_tee":
558
+ _md = MPCETeeData(**elem_data)
559
+ # Resolve areas with inheritance precedence (see _resolve_tee_areas).
560
+ _F_C, _A_branch, _psi = _resolve_tee_areas(
561
+ schema.edges,
562
+ schema.nodes,
563
+ elem_id,
564
+ F_C_explicit=_md.F_C,
565
+ F_branch_explicit=_md.F_branch,
566
+ psi_fallback=_md.psi,
567
+ )
568
+
569
+ _common = _find_tee_port(
570
+ schema.edges, elem_id, "common", nodes_map, tee_port_node_map, _wall_edge_remap
448
571
  )
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)
572
+ _straight = _find_tee_port(
573
+ schema.edges, elem_id, "straight", nodes_map, tee_port_node_map, _wall_edge_remap
574
+ )
575
+ _branch = _find_tee_port(
576
+ schema.edges, elem_id, "branch", nodes_map, tee_port_node_map, _wall_edge_remap
577
+ )
578
+
579
+ # Branch (separating): single inlet at common, two outlets.
580
+ # Merge (joining): two inlets at straight+branch, single outlet at common.
581
+ if _md.flow_direction == "branch":
582
+ _inlets = [_common]
583
+ _outlets = [_straight, _branch]
584
+ _inlet_angles = [0.0]
585
+ _outlet_angles = [0.0, _md.theta_deg]
586
+ _port_areas = [_F_C, _F_C, _A_branch]
587
+ else: # "merge"
588
+ _inlets = [_straight, _branch]
589
+ _outlets = [_common]
590
+ _inlet_angles = [0.0, _md.theta_deg]
591
+ _outlet_angles = [0.0]
592
+ _port_areas = [_F_C, _A_branch, _F_C]
593
+
594
+ # strict=False: strict mode raises whenever a Newton ITERATE
595
+ # (not the converged answer) explores a wrong-sign port
596
+ # m_dot, killing GUI solves that would otherwise converge
597
+ # ("Unexpected error during residual evaluation"). Soft mode
598
+ # lets the solver explore; the post-solve
599
+ # verify_solution_consistent guard in NetworkSolver rejects
600
+ # any wrong-direction artifact root at convergence.
601
+ if _md.junction_model == "constant_k":
602
+ # Simplest-model tier: fixed handbook K per non-common
603
+ # port, referenced to the common-leg dynamic head. Port
604
+ # order is inlets first: branch = [common, straight,
605
+ # branch]; merge = [straight, branch, common].
606
+ if _md.flow_direction == "branch":
607
+ _k_ports = {1: _md.K_straight, 2: _md.K_branch}
608
+ else:
609
+ _k_ports = {0: _md.K_straight, 1: _md.K_branch}
610
+ _mpce = ConstantKTeeElement(
611
+ id=elem_id,
612
+ inlet_nodes=_inlets,
613
+ outlet_nodes=_outlets,
614
+ inlet_angles_deg=_inlet_angles,
615
+ outlet_angles_deg=_outlet_angles,
616
+ port_areas=_port_areas,
617
+ flow_direction=_md.flow_direction,
618
+ strict=False,
619
+ K_ports=_k_ports,
620
+ )
621
+ else:
622
+ _mpce = MPCEv2Element(
623
+ id=elem_id,
624
+ inlet_nodes=_inlets,
625
+ outlet_nodes=_outlets,
626
+ inlet_angles_deg=_inlet_angles,
627
+ outlet_angles_deg=_outlet_angles,
628
+ port_areas=_port_areas,
629
+ flow_direction=_md.flow_direction,
630
+ strict=False,
631
+ joining_etransfer_alpha=_md.joining_etransfer_alpha,
632
+ )
633
+ _mpce.initial_guess = _expand_initial_guess(_md.initial_guess, elem_id)
634
+ if not _mpce.initial_guess:
635
+ _mpce.initial_guess = _guess_from_prior_result(elem_data, elem_id)
636
+ net.add_element(_mpce)
453
637
  continue
454
638
 
455
639
  source_id = None
@@ -473,7 +657,7 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
473
657
  if outgoing_count > 1:
474
658
  raise ValueError(f"Element '{elem_id}' has multiple downstream links.")
475
659
  if edge.target in nodes_map:
476
- target_id = edge.target
660
+ target_id = _wall_edge_remap.get(edge.id, edge.target)
477
661
  elif edge.target in element_ids:
478
662
  target_id = edge_junction_map.get((elem_id, edge.target))
479
663
 
@@ -482,13 +666,15 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
482
666
 
483
667
  if elem_type == "channel":
484
668
  data = ChannelData(**elem_data)
485
- conv_area = 3.1415926535 * data.D * data.L
669
+ # conv_area is deferred to 0 when D=None; resolve_topology updates it
670
+ conv_area = (3.1415926535 * data.D * data.L) if data.D is not None else 0.0
486
671
  elem = ChannelElement(
487
672
  elem_id,
488
673
  from_node=source_id,
489
674
  to_node=target_id,
490
675
  length=data.L,
491
676
  diameter=data.D,
677
+ Dh=data.Dh,
492
678
  roughness=data.roughness,
493
679
  friction_model=data.friction_model,
494
680
  surface=ConvectiveSurface(
@@ -548,17 +734,26 @@ def build_network_from_schema(schema: NetworkGraphSchema) -> FlowNetwork:
548
734
  net.add_element(elem)
549
735
  elif elem_type == "discrete_loss":
550
736
  data = DiscreteLossData(**elem_data)
551
- # Infer area from upstream node if not explicitly set
552
- area = data.area
737
+ # area=None signals "inherit from upstream element in resolve_topology".
738
+ # Use a quick node-area lookup only when data.area is explicitly set or
739
+ # the upstream node exposes a non-zero area (combustor / momentum chamber).
740
+ area: float | None = data.area
553
741
  if area is None and source_id in nodes_map:
554
- area = getattr(nodes_map[source_id], "area", 0.1)
555
- area = area if area and area > 0 else 0.1
742
+ node_area = getattr(nodes_map[source_id], "area", None)
743
+ if node_area and node_area > 0:
744
+ area = node_area
556
745
  correlation = _build_discrete_loss_correlation(
557
- data.correlation_type, data.xi, data.k, data.xi0, data.zeta, data.zeta0, area
746
+ data.correlation_type,
747
+ data.xi,
748
+ data.k,
749
+ data.xi0,
750
+ data.zeta,
751
+ data.zeta0,
752
+ area if area and area > 0 else 0.1,
558
753
  )
559
754
  theta_source = data.theta_source if data.theta_source not in (None, "none") else None
560
755
  surface = ConvectiveSurface(
561
- area=area,
756
+ area=area if area and area > 0 else 0.0,
562
757
  model=map_surface_model(data.surface),
563
758
  Nu_multiplier=data.Nu_multiplier,
564
759
  f_multiplier=data.f_multiplier,
@@ -128,7 +128,8 @@ def _solve_sync(schema: NetworkGraphSchema):
128
128
  }
129
129
 
130
130
  node_results, element_results, edge_results = _build_result_objects(result, net)
131
- return result, node_results, element_results, edge_results, net
131
+ diag = getattr(solver, "_diagnostic_data", {})
132
+ return result, node_results, element_results, edge_results, net, diag
132
133
 
133
134
 
134
135
  @app.post("/solve", response_model=SolveResponse)
@@ -140,7 +141,7 @@ async def solve(schema: NetworkGraphSchema):
140
141
  hard_timeout = soft_timeout + 10.0
141
142
  try:
142
143
  # Offload CPU-bound C++ solver to a worker thread
143
- result, node_results, element_results, edge_results, _ = await asyncio.wait_for(
144
+ result, node_results, element_results, edge_results, _, diag = await asyncio.wait_for(
144
145
  asyncio.to_thread(_solve_sync, schema),
145
146
  timeout=hard_timeout,
146
147
  )
@@ -152,12 +153,17 @@ async def solve(schema: NetworkGraphSchema):
152
153
  node_results=node_results,
153
154
  element_results=element_results,
154
155
  edge_results=edge_results,
156
+ convergence_history=diag.get("convergence_history", []),
157
+ worst_residuals=diag.get("worst_residuals", []),
158
+ solver_settings_used=diag.get("solver_settings_used", {}),
159
+ lm_started_at_eval=diag.get("lm_started_at_eval"),
155
160
  )
156
161
  except TimeoutError:
157
162
  raise HTTPException(
158
163
  status_code=504,
159
164
  detail=f"Solver exceeded the hard timeout of {hard_timeout:.0f}s. "
160
- "Try a shorter timeout, a simpler network, or the homotopy init strategy.",
165
+ "Try raising the solver timeout, simplifying the network, or a "
166
+ "different init strategy.",
161
167
  ) from None
162
168
  except ValueError as e:
163
169
  raise HTTPException(status_code=400, detail=str(e)) from e
@@ -230,8 +230,6 @@ class NetworkResult:
230
230
  with all available diagnostic quantities. Column names carry unit
231
231
  annotations matching the GUI export (e.g. ``'P [Pa]'``, ``'T [K]'``).
232
232
  """
233
- from combaero.network.components import TeeJunctionElement as _TeeJE
234
-
235
233
  _BOUNDARY_KINDS = {"mass_boundary", "pressure_boundary"}
236
234
  species_labels: list[str] = list(cb.species.names)
237
235
  data: list[dict] = []
@@ -261,22 +259,6 @@ class NetworkResult:
261
259
 
262
260
  for elem_id, res in self._element_results.items():
263
261
  elem_data = res.model_dump()
264
- elem_obj = self._net.elements.get(elem_id)
265
- if isinstance(elem_obj, _TeeJE):
266
- common_res = self._node_results.get(elem_obj.common_node)
267
- if common_res:
268
- common_state = common_res.state.model_dump()
269
- Y = common_state.pop("Y", [])
270
- X = common_state.pop("X", None) or []
271
- for k, v in common_state.items():
272
- if v is not None:
273
- elem_data[k] = v
274
- for i, y_val in enumerate(Y):
275
- name = species_labels[i] if i < len(species_labels) else str(i)
276
- elem_data[f"Y[{name}]"] = y_val
277
- for i, x_val in enumerate(X):
278
- name = species_labels[i] if i < len(species_labels) else str(i)
279
- elem_data[f"X[{name}]"] = x_val
280
262
  data.append(
281
263
  {
282
264
  "type": "element",
@@ -88,6 +88,12 @@ class PressureBoundaryData(BaseModel):
88
88
  label: str | None = None
89
89
 
90
90
 
91
+ class WallData(BaseModel):
92
+ model_config = ConfigDict(extra="ignore")
93
+ label: str | None = None
94
+ initial_guess: dict[str, float] = Field(default_factory=dict)
95
+
96
+
91
97
  class ConstantFractionLossData(BaseModel):
92
98
  model_config = ConfigDict(extra="ignore")
93
99
  type: Literal["constant_fraction"] = "constant_fraction"
@@ -125,7 +131,7 @@ PressureLossData = (
125
131
  class CombustorData(BaseModel):
126
132
  model_config = ConfigDict(extra="ignore")
127
133
  method: Literal["complete", "equilibrium"] = "complete"
128
- area: float = 0.1
134
+ area: float | None = None # None = derive from Dh (pi/4 * Dh^2)
129
135
  Dh: float | None = None
130
136
  surface: SurfaceModelData = Field(default_factory=SmoothModelData)
131
137
  Nu_multiplier: float = 1.0
@@ -136,8 +142,8 @@ class CombustorData(BaseModel):
136
142
 
137
143
  class MomentumChamberData(BaseModel):
138
144
  model_config = ConfigDict(extra="ignore")
139
- area: float = 0.1
140
- Dh: float = 0.1
145
+ area: float | None = None # None = derive from Dh (pi/4 * Dh^2)
146
+ Dh: float | None = None # None = inherit from upstream channel (Dh = D)
141
147
  surface: SurfaceModelData = Field(default_factory=SmoothModelData)
142
148
  Nu_multiplier: float = 1.0
143
149
  f_multiplier: float = 1.0
@@ -151,7 +157,8 @@ class MomentumChamberData(BaseModel):
151
157
  class ChannelData(BaseModel):
152
158
  model_config = ConfigDict(extra="ignore")
153
159
  L: float = 1.0
154
- D: float = 0.1
160
+ D: float | None = None # None = inherit from upstream element geometry
161
+ Dh: float | None = None # None = circular (Dh = D); override for non-circular ducts
155
162
  roughness: float = 1e-5
156
163
  friction_model: Literal["haaland", "serghides", "colebrook", "petukhov"] = "haaland"
157
164
  surface: SurfaceModelData = Field(default_factory=SmoothModelData)
@@ -164,7 +171,7 @@ class ChannelData(BaseModel):
164
171
 
165
172
  class OrificeData(BaseModel):
166
173
  model_config = ConfigDict(extra="ignore")
167
- diameter: float = 0.08
174
+ diameter: float | None = None # None = inherit from upstream channel
168
175
  Cd: float = 0.6
169
176
  correlation: Literal[
170
177
  "ReaderHarrisGallagher", "Stolz", "Miller", "ThickPlate", "RoundedEntry", "fixed"
@@ -190,8 +197,8 @@ class OrificeData(BaseModel):
190
197
  class AreaChangeData(BaseModel):
191
198
  model_config = ConfigDict(extra="ignore")
192
199
  model_type: Literal["sharp", "conical"] = "sharp"
193
- F0: float = 0.01
194
- F1: float = 0.02
200
+ F0: float | None = None # None = inherit from upstream channel (A = pi/4*D^2)
201
+ F1: float | None = None # None = inherit from downstream channel
195
202
  length: float | None = None
196
203
  D_h: float = 0.0
197
204
  initial_guess: dict[str, float] = Field(default_factory=dict)
@@ -202,13 +209,35 @@ class LosslessConnectionData(BaseModel):
202
209
  pass
203
210
 
204
211
 
205
- class TeeJunctionData(BaseModel):
212
+ class MPCETeeData(BaseModel):
213
+ """Mynard-based multi-port chamber tee element with constrained topology.
214
+
215
+ 3-port junction using Mynard's Unified0D pseudosupplier residual. The
216
+ ``flow_direction`` field declares whether this junction is a merge
217
+ (joining flow, 2 inlets + 1 outlet) or a branch (separating flow,
218
+ 1 inlet + 2 outlets); the runtime element raises if the observed
219
+ flow pattern disagrees.
220
+ """
221
+
206
222
  model_config = ConfigDict(extra="ignore")
207
223
  label: str | None = None
208
- tee_type: str = "merging" # "merging" | "branching"
224
+ flow_direction: Literal["merge", "branch"] = "branch"
225
+ # Junction closure model. "mynard" (default) is the full Unified0D
226
+ # pseudosupplier physics; "constant_k" is the simplest-model tier:
227
+ # fixed handbook loss coefficients referenced to the common-leg
228
+ # dynamic head (Idelchik convention), K independent of the split.
229
+ junction_model: Literal["mynard", "constant_k"] = "mynard"
230
+ K_straight: float = 0.4 # constant_k only: straight-arm loss coeff [-]
231
+ K_branch: float = 1.0 # constant_k only: branch-arm loss coeff [-]
209
232
  theta_deg: float = 90.0 # branch angle [deg], converted to radians in graph_builder
210
- F_C: float = 0.01 # common-arm area [m^2]
211
- psi: float = 1.0 # F_C / F_S area ratio
233
+ F_C: float | None = None # None = inherit from common/straight-arm channel
234
+ F_branch: float | None = None # None = inherit from branch-arm channel
235
+ psi: float = 1.0 # fallback ratio used only when F_branch is None and not inherited
236
+ # Joining-side etransfer correction scale (combaero extension to faithful
237
+ # Mynard). Default 0.2 calibrated against Bassett K11/K12 analytical +
238
+ # Idelchik 1966 tabulated values. Set to 0.0 to disable the correction
239
+ # (faithful Mynard); set to a custom value for re-calibrated workflows.
240
+ joining_etransfer_alpha: float | None = None
212
241
  initial_guess: dict[str, float] = Field(default_factory=dict)
213
242
 
214
243
 
@@ -276,9 +305,14 @@ class ThermalWallData(BaseModel):
276
305
  class SolverSettings(BaseModel):
277
306
  model_config = ConfigDict(extra="ignore")
278
307
  global_regime: Literal["incompressible", "compressible"] = "incompressible"
279
- init_strategy: Literal["default", "incompressible_warmstart", "homotopy", "continuation"] = (
280
- "default"
281
- )
308
+ init_strategy: Literal[
309
+ "default",
310
+ "analytical_pt_prop",
311
+ "incompressible_warmstart",
312
+ "outletref_warmstart",
313
+ "homotopy",
314
+ "continuation",
315
+ ] = "default"
282
316
  method: Literal[
283
317
  "hybr",
284
318
  "lm",
@@ -375,6 +409,17 @@ class ElementResult(BaseModel):
375
409
  success: bool = True
376
410
 
377
411
 
412
+ class ConvergencePoint(BaseModel):
413
+ eval: int
414
+ t: float
415
+ norm: float
416
+
417
+
418
+ class WorstResidual(BaseModel):
419
+ name: str
420
+ residual: float
421
+
422
+
378
423
  class SolveResponse(BaseModel):
379
424
  success: bool
380
425
  message: str = ""
@@ -382,3 +427,7 @@ class SolveResponse(BaseModel):
382
427
  node_results: dict[str, NodeResult] = {}
383
428
  element_results: dict[str, ElementResult] = {}
384
429
  edge_results: dict[str, dict] = {}
430
+ convergence_history: list[ConvergencePoint] = []
431
+ worst_residuals: list[WorstResidual] = []
432
+ solver_settings_used: dict = {}
433
+ lm_started_at_eval: int | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combaero-gui
3
- Version: 0.3.1.dev2
3
+ Version: 0.4.2
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.3
24
+ Requires-Dist: combaero~=0.4
25
25
  Provides-Extra: gui
26
26
 
27
27
  # CombAero Network Designer GUI