combaero-gui 0.2.0__py3-none-any.whl

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.
@@ -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,11 @@
1
+ gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ gui/backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ gui/backend/graph_builder.py,sha256=4HpY5dQFkeBDVRzR1AU9A27e4-YfSqcimvPNWhkRsOE,16815
4
+ gui/backend/main.py,sha256=sv4YBiMDM8UC2xwaUUJzNCjzj5aQlEzYbvvpoNcv1gQ,12681
5
+ gui/backend/schemas.py,sha256=KWtaAkEukLLQAzK-3SFUwwHL-rnqNqinfTktrqz6PjQ,9577
6
+ gui/backend/units.py,sha256=TiUR93ffHE_YlnNhjaIVxH8VhiXQny3cqVRIw-fxuF8,3318
7
+ combaero_gui-0.2.0.dist-info/METADATA,sha256=awJV4DNleCPvr_UKNw0w0wFb-zVGtXQRFC_R7N0y6k8,2938
8
+ combaero_gui-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ combaero_gui-0.2.0.dist-info/entry_points.txt,sha256=LpbuqFqQX0xAVvATI-7bH0DLyp1A77d2cupUJPUzNMs,61
10
+ combaero_gui-0.2.0.dist-info/top_level.txt,sha256=3YomgRPcdOHZ7RJGM04iRwXaP3X7OdcEO_G3JfYoHio,4
11
+ combaero_gui-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ combaero-gui = gui.backend.main:run_server
@@ -0,0 +1 @@
1
+ gui
gui/__init__.py ADDED
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
gui/backend/main.py ADDED
@@ -0,0 +1,359 @@
1
+ import asyncio
2
+ import io
3
+
4
+ from fastapi import FastAPI, HTTPException
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import StreamingResponse
7
+
8
+ import combaero as cb
9
+ from combaero.network import NetworkSolver
10
+
11
+ from .graph_builder import build_network_from_schema
12
+ from .schemas import (
13
+ ElementResult,
14
+ NetworkGraphSchema,
15
+ NodeResult,
16
+ SolveResponse,
17
+ StateResult,
18
+ )
19
+
20
+ app = FastAPI(title="CombAero Network GUI API")
21
+
22
+ # Configure CORS for local development (Vite typically runs on 5173)
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+
32
+ @app.get("/")
33
+ async def root():
34
+ return {
35
+ "message": "CombAero Network GUI API is running",
36
+ "docs": "/docs",
37
+ "health": "/health",
38
+ "metadata": ["/metadata/species", "/metadata/presets", "/metadata/materials"],
39
+ }
40
+
41
+
42
+ @app.get("/metadata/species")
43
+ async def get_species():
44
+ """Returns available species metadata from the C++ core."""
45
+
46
+ return {
47
+ "names": cb.species.names,
48
+ "molar_masses": cb.species._default_locator.molar_masses.tolist(),
49
+ }
50
+
51
+
52
+ @app.get("/metadata/presets")
53
+ async def get_presets():
54
+ """Returns available composition presets."""
55
+ return {
56
+ "sources": ["dry_air", "humid_air", "fuel", "custom"],
57
+ "modes": ["mole", "mass"],
58
+ }
59
+
60
+
61
+ @app.get("/metadata/materials")
62
+ async def get_materials():
63
+ """Returns available wall materials from the database."""
64
+ return {
65
+ "names": cb.list_materials(),
66
+ }
67
+
68
+
69
+ # Module-level persistence for warm-starting the solver across requests.
70
+ _continuation_state: dict | None = None
71
+
72
+
73
+ def _solve_sync(schema: NetworkGraphSchema):
74
+ """
75
+ Synchronous helper to build and solve the network.
76
+ Runs in a separate thread to avoid blocking the FastAPI event loop.
77
+ """
78
+ global _continuation_state
79
+ net = build_network_from_schema(schema)
80
+ solver = NetworkSolver(net)
81
+ x0 = None
82
+ if schema.solver_settings.init_strategy == "continuation" and _continuation_state:
83
+ # Check topology fingerprint
84
+ solver._build_x0()
85
+ if _continuation_state["unknown_names"] != list(solver.unknown_names):
86
+ raise ValueError(
87
+ "Topology mismatch: the current network unknowns do not match "
88
+ "the saved continuation state. Please run a 'Default' solve first."
89
+ )
90
+
91
+ # Safety Reset: Check if initial guesses have changed
92
+ current_guesses = {n.id: n.data.get("initial_guess", {}) for n in schema.nodes}
93
+ if _continuation_state.get("initial_guesses") != current_guesses:
94
+ raise ValueError(
95
+ "Initial guess mismatch: user overrides have changed since the last solve. "
96
+ "Please run a 'Default' solve to incorporate these changes."
97
+ )
98
+
99
+ x0 = _continuation_state["x"]
100
+
101
+ result = solver.solve(
102
+ method=schema.solver_settings.method,
103
+ init_strategy=schema.solver_settings.init_strategy,
104
+ timeout=schema.solver_settings.timeout,
105
+ x0=x0,
106
+ )
107
+ success = bool(result.get("__success__", False))
108
+
109
+ if success:
110
+ _continuation_state = {
111
+ "x": result["__x_solution__"],
112
+ "unknown_names": result["__unknown_names__"],
113
+ "initial_guesses": {n.id: n.data.get("initial_guess", {}) for n in schema.nodes},
114
+ }
115
+
116
+ node_results = {}
117
+ for node_id in net.nodes:
118
+ # Pull all node diagnostics and state variables directly from sol_dict
119
+ # flat keys are like node_id.T, node_id.P, node_id.mach, node_id.rho, etc.
120
+ prefix = f"{node_id}."
121
+ node_vals = {k[len(prefix) :]: v for k, v in result.items() if k.startswith(prefix)}
122
+
123
+ # Handle composition lists (Y and X)
124
+ y_vals = []
125
+ x_vals = []
126
+ i = 0
127
+ while f"Y[{i}]" in node_vals:
128
+ y_vals.append(float(node_vals.pop(f"Y[{i}]")))
129
+ if f"X[{i}]" in node_vals:
130
+ x_vals.append(float(node_vals.pop(f"X[{i}]")))
131
+ i += 1
132
+
133
+ state_res = StateResult(
134
+ T=float(node_vals.pop("T", 300.0)),
135
+ P=float(node_vals.pop("P", 101325.0)),
136
+ Pt=node_vals.pop("Pt", None),
137
+ Tt=node_vals.pop("Tt", None),
138
+ Y=y_vals,
139
+ X=x_vals or None,
140
+ **node_vals, # All remaining diagnostics (rho, mach, mu, k, etc.)
141
+ )
142
+ node_results[node_id] = NodeResult(state=state_res, success=success)
143
+
144
+ element_results = {}
145
+ elem_diags = result.get("__element_diag__", {})
146
+ for elem_id in net.elements:
147
+ m_dot = float(result.get(f"{elem_id}.m_dot", 0.0))
148
+ # Pull element diagnostics from the structured dict aggregated by solve()
149
+ diag = elem_diags.get(elem_id, {}).copy()
150
+ diag.pop("m_dot", None)
151
+ element_results[elem_id] = ElementResult(m_dot=m_dot, success=success, **diag)
152
+
153
+ edge_results = {}
154
+
155
+ def _safe_float(v):
156
+ try:
157
+ if isinstance(v, (list, tuple, dict)):
158
+ return v
159
+ return float(v)
160
+ except (TypeError, ValueError):
161
+ return v
162
+
163
+ for edge_id in net.walls:
164
+ edge_keys = {
165
+ k.split(".", 1)[1]: _safe_float(v)
166
+ for k, v in result.items()
167
+ if k.startswith(f"{edge_id}.")
168
+ }
169
+ if edge_keys:
170
+ edge_results[edge_id] = edge_keys
171
+
172
+ return result, node_results, element_results, edge_results, net
173
+
174
+
175
+ @app.post("/solve", response_model=SolveResponse)
176
+ async def solve(schema: NetworkGraphSchema):
177
+ # Hard async-level deadline: even if a single C++ Fanno/RK4 step blocks,
178
+ # the event loop stays alive for health checks and subsequent requests.
179
+ # Add a 10 s buffer on top of the solver's own soft timeout.
180
+ soft_timeout = schema.solver_settings.timeout or 180.0
181
+ hard_timeout = soft_timeout + 10.0
182
+ try:
183
+ # Offload CPU-bound C++ solver to a worker thread
184
+ result, node_results, element_results, edge_results, _ = await asyncio.wait_for(
185
+ asyncio.to_thread(_solve_sync, schema),
186
+ timeout=hard_timeout,
187
+ )
188
+
189
+ return SolveResponse(
190
+ success=result.get("__success__", False),
191
+ message=result.get("__message__", "Solve completed"),
192
+ final_norm=result.get("__final_norm__"),
193
+ node_results=node_results,
194
+ element_results=element_results,
195
+ edge_results=edge_results,
196
+ )
197
+ except TimeoutError:
198
+ raise HTTPException(
199
+ status_code=504,
200
+ detail=f"Solver exceeded the hard timeout of {hard_timeout:.0f}s. "
201
+ "Try a shorter timeout, a simpler network, or the homotopy init strategy.",
202
+ ) from None
203
+ except ValueError as e:
204
+ raise HTTPException(status_code=400, detail=str(e)) from e
205
+ except Exception as e:
206
+ raise HTTPException(status_code=500, detail=str(e)) from e
207
+
208
+
209
+ @app.post("/export")
210
+ async def export_results(schema: NetworkGraphSchema):
211
+ soft_timeout = schema.solver_settings.timeout or 180.0
212
+ hard_timeout = soft_timeout + 10.0
213
+ try:
214
+ # Solve quickly to get states
215
+ _, node_results, element_results, edge_results, _ = await asyncio.wait_for(
216
+ asyncio.to_thread(_solve_sync, schema),
217
+ timeout=hard_timeout,
218
+ )
219
+
220
+ # Convert to DataFrame
221
+ import pandas as pd
222
+
223
+ # Build unified id_to_label map from nodes, edges, and auto-links
224
+ id_to_label = {}
225
+ # Map each schema node/edge id to its declared ``type`` so the CSV can
226
+ # preserve specific-kind information (plenum vs combustor vs
227
+ # discrete_loss, …) needed for later round-trips or sweep workflows.
228
+ id_to_kind: dict[str, str] = {}
229
+ for n in schema.nodes:
230
+ id_to_label[n.id] = n.data.get("label") or ""
231
+ id_to_kind[n.id] = n.type
232
+ for e in schema.edges:
233
+ lbl = (e.data or {}).get("label") or ""
234
+ id_to_label[e.id] = lbl
235
+ id_to_kind[e.id] = (e.data or {}).get("type") or "flow"
236
+ # Also map solver auto-link IDs
237
+ auto_id = f"__auto_link__{e.source}__{e.target}"
238
+ id_to_label[auto_id] = lbl
239
+ id_to_kind[auto_id] = "lossless_connection"
240
+
241
+ _BOUNDARY_KINDS = {"mass_boundary", "pressure_boundary"}
242
+
243
+ # Per-node combustion method (empty string for non-combustor rows).
244
+ id_to_method: dict[str, str] = {}
245
+ for n in schema.nodes:
246
+ if n.type == "combustor":
247
+ id_to_method[n.id] = n.data.get("method", "complete")
248
+
249
+ # Species labels for composition column headers (``Y[N2]`` not ``Y[0]``).
250
+ import combaero as cb
251
+
252
+ species_labels: list[str] = list(cb.species.names)
253
+
254
+ data = []
255
+
256
+ # 1. Process Nodes
257
+ for node_id, res in node_results.items():
258
+ # Include all fields from StateResult (rho, mach, k, etc.)
259
+ state_data = res.state.model_dump()
260
+ # Handle Y/X list expansion
261
+ Y = state_data.pop("Y", [])
262
+ X = state_data.pop("X", None)
263
+
264
+ kind = id_to_kind.get(node_id, "")
265
+ base_data = {
266
+ "type": "node",
267
+ "kind": kind,
268
+ "is_boundary": kind in _BOUNDARY_KINDS,
269
+ "combustion_method": id_to_method.get(node_id, ""),
270
+ "id": node_id,
271
+ "label": id_to_label.get(node_id, ""),
272
+ **state_data,
273
+ }
274
+ # Flatten species fractions with species-name indexing.
275
+ for i, y_val in enumerate(Y):
276
+ name = species_labels[i] if i < len(species_labels) else str(i)
277
+ base_data[f"Y[{name}]"] = y_val
278
+ if X:
279
+ for i, x_val in enumerate(X):
280
+ name = species_labels[i] if i < len(species_labels) else str(i)
281
+ base_data[f"X[{name}]"] = x_val
282
+
283
+ data.append(base_data)
284
+
285
+ # 2. Process Flow Elements
286
+ for elem_id, res in element_results.items():
287
+ # Include all analytic/diagnostic fields (Re, Nu, h, q_dot, f, etc.)
288
+ elem_data = res.model_dump()
289
+ data.append(
290
+ {
291
+ "type": "element",
292
+ "kind": id_to_kind.get(elem_id, ""),
293
+ "is_boundary": False,
294
+ "combustion_method": "",
295
+ "id": elem_id,
296
+ "label": id_to_label.get(elem_id, ""),
297
+ **elem_data,
298
+ }
299
+ )
300
+
301
+ # 3. Process Thermal Walls (edge_results)
302
+ for wall_id, res in edge_results.items():
303
+ # thermal wall results are already a dict from _solve_sync
304
+ data.append(
305
+ {
306
+ "type": "thermal_wall",
307
+ "kind": "thermal_wall",
308
+ "is_boundary": False,
309
+ "combustion_method": "",
310
+ "id": wall_id,
311
+ "label": id_to_label.get(wall_id, ""),
312
+ **res,
313
+ }
314
+ )
315
+
316
+ df = pd.DataFrame(data)
317
+
318
+ # Rename columns with unit annotations (e.g. ``P`` -> ``P [Pa]``).
319
+ # Meta columns (``id``, ``type``, ``kind``, ``is_boundary``, ...) are
320
+ # passed through unchanged; unknown columns also remain untouched.
321
+ from gui.backend.units import label_with_unit
322
+
323
+ df = df.rename(columns={col: label_with_unit(col) for col in df.columns})
324
+
325
+ # Stream as CSV
326
+ stream = io.StringIO()
327
+ df.to_csv(stream, index=False)
328
+
329
+ return StreamingResponse(
330
+ iter([stream.getvalue()]),
331
+ media_type="text/csv",
332
+ headers={"Content-Disposition": "attachment; filename=combaero_results.csv"},
333
+ )
334
+ except TimeoutError:
335
+ raise HTTPException(
336
+ status_code=504,
337
+ detail=f"Export solve exceeded the hard timeout of {hard_timeout:.0f}s.",
338
+ ) from None
339
+ except ValueError as e:
340
+ raise HTTPException(status_code=400, detail=str(e)) from e
341
+ except Exception as e:
342
+ raise HTTPException(status_code=500, detail=str(e)) from e
343
+
344
+
345
+ @app.get("/solver/continuation_available")
346
+ async def get_continuation_available():
347
+ """Returns whether a converged state exists for continuation."""
348
+ return {"available": _continuation_state is not None}
349
+
350
+
351
+ @app.get("/health")
352
+ async def health():
353
+ return {"status": "ok"}
354
+
355
+
356
+ def run_server(host: str = "127.0.0.1", port: int = 8000) -> None:
357
+ import uvicorn
358
+
359
+ uvicorn.run(app, host=host, port=port)
gui/backend/schemas.py ADDED
@@ -0,0 +1,340 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
4
+
5
+
6
+ class SmoothModelData(BaseModel):
7
+ model_config = ConfigDict(extra="ignore")
8
+ type: Literal["smooth"] = "smooth"
9
+
10
+
11
+ class RibbedModelData(BaseModel):
12
+ model_config = ConfigDict(extra="ignore")
13
+ type: Literal["ribbed"] = "ribbed"
14
+ e_D: float = 0.05
15
+ pitch_to_height: float = 10.0
16
+ alpha_deg: float = 90.0
17
+
18
+
19
+ class DimpledModelData(BaseModel):
20
+ model_config = ConfigDict(extra="ignore")
21
+ type: Literal["dimpled"] = "dimpled"
22
+ d_Dh: float = 0.2
23
+ h_d: float = 0.15
24
+ S_d: float = 2.0
25
+
26
+
27
+ class PinFinModelData(BaseModel):
28
+ model_config = ConfigDict(extra="ignore")
29
+ type: Literal["pin_fin"] = "pin_fin"
30
+ pin_diameter: float = 0.005
31
+ channel_height: float = 0.01 # [m]
32
+ S_D: float = 2.5
33
+ X_D: float = 2.5
34
+ N_rows: int = 10
35
+ is_staggered: bool = True
36
+
37
+
38
+ class ImpingementModelData(BaseModel):
39
+ model_config = ConfigDict(extra="ignore")
40
+ type: Literal["impingement"] = "impingement"
41
+ d_jet: float = 0.002
42
+ z_D: float = 4.0
43
+ x_D: float = 6.0
44
+ y_D: float = 6.0
45
+ A_target: float = 0.01
46
+ Cd_jet: float = 0.8
47
+
48
+
49
+ SurfaceModelData = (
50
+ SmoothModelData | RibbedModelData | DimpledModelData | PinFinModelData | ImpingementModelData
51
+ )
52
+
53
+
54
+ # --- Node Data Definitions ---
55
+
56
+
57
+ class PlenumData(BaseModel):
58
+ model_config = ConfigDict(extra="ignore")
59
+ initial_guess: dict[str, float] = Field(default_factory=dict)
60
+ label: str | None = None
61
+
62
+
63
+ class CompositionData(BaseModel):
64
+ model_config = ConfigDict(extra="ignore")
65
+ mode: Literal["mole", "mass"] = "mole"
66
+ source: Literal["dry_air", "humid_air", "fuel", "custom"] = "dry_air"
67
+ custom_fractions: dict[str, float] | None = None
68
+ relative_humidity: float = 0.6 # [0-1] for humid_air
69
+ ambient_T: float = 288.15
70
+ ambient_P: float = 101325.0
71
+
72
+
73
+ class MassBoundaryData(BaseModel):
74
+ model_config = ConfigDict(extra="ignore")
75
+ m_dot: float = 1.0
76
+ Tt: float = 300.0
77
+ composition: CompositionData = Field(default_factory=CompositionData)
78
+ initial_guess: dict[str, float] = Field(default_factory=dict)
79
+ label: str | None = None
80
+
81
+
82
+ class PressureBoundaryData(BaseModel):
83
+ model_config = ConfigDict(extra="ignore")
84
+ Pt: float = 101325.0
85
+ Tt: float = 300.0
86
+ composition: CompositionData = Field(default_factory=CompositionData)
87
+ initial_guess: dict[str, float] = Field(default_factory=dict)
88
+ label: str | None = None
89
+
90
+
91
+ class ConstantFractionLossData(BaseModel):
92
+ model_config = ConfigDict(extra="ignore")
93
+ type: Literal["constant_fraction"] = "constant_fraction"
94
+ xi: float = 0.03
95
+
96
+
97
+ class LinearThetaFractionLossData(BaseModel):
98
+ model_config = ConfigDict(extra="ignore")
99
+ type: Literal["linear_theta_fraction"] = "linear_theta_fraction"
100
+ k: float = 0.5
101
+ xi0: float = 0.02
102
+
103
+
104
+ class ConstantHeadLossData(BaseModel):
105
+ model_config = ConfigDict(extra="ignore")
106
+ type: Literal["constant_head"] = "constant_head"
107
+ zeta: float = 5.0
108
+
109
+
110
+ class LinearThetaHeadLossData(BaseModel):
111
+ model_config = ConfigDict(extra="ignore")
112
+ type: Literal["linear_theta_head"] = "linear_theta_head"
113
+ k: float = 1.0
114
+ zeta0: float = 3.0
115
+
116
+
117
+ PressureLossData = (
118
+ ConstantFractionLossData
119
+ | LinearThetaFractionLossData
120
+ | ConstantHeadLossData
121
+ | LinearThetaHeadLossData
122
+ )
123
+
124
+
125
+ class CombustorData(BaseModel):
126
+ model_config = ConfigDict(extra="ignore")
127
+ method: Literal["complete", "equilibrium"] = "complete"
128
+ area: float = 0.1
129
+ Dh: float | None = None
130
+ surface: SurfaceModelData = Field(default_factory=SmoothModelData)
131
+ Nu_multiplier: float = 1.0
132
+ f_multiplier: float = 1.0
133
+ initial_guess: dict[str, float] = Field(default_factory=dict)
134
+ label: str | None = None
135
+
136
+
137
+ class MomentumChamberData(BaseModel):
138
+ model_config = ConfigDict(extra="ignore")
139
+ area: float = 0.1
140
+ Dh: float = 0.1
141
+ surface: SurfaceModelData = Field(default_factory=SmoothModelData)
142
+ Nu_multiplier: float = 1.0
143
+ f_multiplier: float = 1.0
144
+ initial_guess: dict[str, float] = Field(default_factory=dict)
145
+ label: str | None = None
146
+
147
+
148
+ # --- Element Data Definitions ---
149
+
150
+
151
+ class ChannelData(BaseModel):
152
+ model_config = ConfigDict(extra="ignore")
153
+ L: float = 1.0
154
+ D: float = 0.1
155
+ roughness: float = 1e-5
156
+ surface: SurfaceModelData = Field(default_factory=SmoothModelData)
157
+ Nu_multiplier: float = 1.0
158
+ f_multiplier: float = 1.0
159
+ regime: Literal["default", "incompressible", "compressible"] = "default"
160
+ initial_guess: dict[str, float] = Field(default_factory=dict)
161
+ label: str | None = None
162
+
163
+
164
+ class OrificeData(BaseModel):
165
+ model_config = ConfigDict(extra="ignore")
166
+ diameter: float = 0.08
167
+ Cd: float = 0.6
168
+ correlation: Literal[
169
+ "ReaderHarrisGallagher", "Stolz", "Miller", "ThickPlate", "RoundedEntry", "fixed"
170
+ ] = "ReaderHarrisGallagher"
171
+ plate_thickness: float = 0.0 # t [m]
172
+ edge_radius: float = 0.0 # r [m]
173
+ regime: Literal["default", "incompressible", "compressible"] = "default"
174
+ initial_guess: dict[str, float] = Field(default_factory=dict)
175
+ label: str | None = None
176
+
177
+ @model_validator(mode="before")
178
+ @classmethod
179
+ def migrate_area_to_diameter(cls, data: dict) -> dict:
180
+ if isinstance(data, dict) and "area" in data and "diameter" not in data:
181
+ import math
182
+
183
+ area_val = data.pop("area")
184
+ if area_val > 0:
185
+ data["diameter"] = math.sqrt(4.0 * area_val / math.pi)
186
+ return data
187
+
188
+
189
+ class AreaChangeData(BaseModel):
190
+ model_config = ConfigDict(extra="ignore")
191
+ model_type: Literal["sharp", "conical"] = "sharp"
192
+ F0: float = 0.01
193
+ F1: float = 0.02
194
+ length: float | None = None
195
+ D_h: float = 0.0
196
+ initial_guess: dict[str, float] = Field(default_factory=dict)
197
+ label: str | None = None
198
+
199
+
200
+ class LosslessConnectionData(BaseModel):
201
+ pass
202
+
203
+
204
+ class DiscreteLossData(BaseModel):
205
+ model_config = ConfigDict(extra="ignore")
206
+ correlation_type: Literal[
207
+ "constant_fraction", "constant_head", "linear_theta_fraction", "linear_theta_head"
208
+ ] = "constant_fraction"
209
+ xi: float = 0.03
210
+ k: float = 0.001
211
+ xi0: float = 0.02
212
+ zeta: float = 1.0
213
+ zeta0: float = 1.0
214
+ area: float | None = None
215
+ theta_source: str | None = None
216
+ surface: SurfaceModelData = Field(default_factory=SmoothModelData)
217
+ Nu_multiplier: float = 1.0
218
+ f_multiplier: float = 1.0
219
+ label: str | None = None
220
+ initial_guess: dict[str, float] = Field(default_factory=dict)
221
+
222
+
223
+ class WallLayerData(BaseModel):
224
+ model_config = ConfigDict(extra="ignore")
225
+ thickness: float = 0.003
226
+ conductivity: float = 20.0
227
+ material: str = "generic"
228
+
229
+
230
+ class ThermalWallData(BaseModel):
231
+ model_config = ConfigDict(extra="ignore")
232
+ type: Literal["thermal"] = "thermal"
233
+ # Support both single-layer (legacy) and multi-layer
234
+ thickness: float | None = None
235
+ conductivity: float | None = None
236
+ layers: list[WallLayerData] | None = None
237
+ area: float | None = 0.05
238
+ R_fouling: float = 0.0
239
+
240
+ @model_validator(mode="before")
241
+ @classmethod
242
+ def migrate_legacy_fields(cls, data: dict) -> dict:
243
+ if isinstance(data, dict) and ("layers" not in data or data["layers"] is None):
244
+ # If we have legacy fields but no layers, migrate them
245
+ t = data.get("thickness", 0.003)
246
+ k = data.get("conductivity", 20.0)
247
+ # Ensure they are not None
248
+ t = t if t is not None else 0.003
249
+ k = k if k is not None else 20.0
250
+ data["layers"] = [{"thickness": t, "conductivity": k}]
251
+ return data
252
+
253
+
254
+ class SolverSettings(BaseModel):
255
+ model_config = ConfigDict(extra="ignore")
256
+ global_regime: Literal["incompressible", "compressible"] = "incompressible"
257
+ init_strategy: Literal["default", "incompressible_warmstart", "homotopy", "continuation"] = (
258
+ "default"
259
+ )
260
+ method: Literal[
261
+ "hybr",
262
+ "lm",
263
+ "broyden1",
264
+ "broyden2",
265
+ "anderson",
266
+ "linearmixing",
267
+ "diagbroyden",
268
+ "excitingmixing",
269
+ "krylov",
270
+ "df-sane",
271
+ ] = "hybr"
272
+ timeout: float | None = 180.0
273
+
274
+
275
+ # --- React Flow Wrapper Schemas ---
276
+
277
+
278
+ class NodePosition(BaseModel):
279
+ model_config = ConfigDict(extra="ignore")
280
+ x: float
281
+ y: float
282
+
283
+
284
+ class ReactFlowNode(BaseModel):
285
+ id: str
286
+ type: str
287
+ position: NodePosition
288
+ data: dict
289
+
290
+
291
+ class ReactFlowEdge(BaseModel):
292
+ id: str
293
+ source: str
294
+ target: str
295
+ type: str | None = None
296
+ data: dict | None = None
297
+
298
+
299
+ class NetworkGraphSchema(BaseModel):
300
+ nodes: list[ReactFlowNode]
301
+ edges: list[ReactFlowEdge]
302
+ solver_settings: SolverSettings = Field(default_factory=SolverSettings)
303
+
304
+
305
+ # --- Results Schemas ---
306
+
307
+
308
+ class StateResult(BaseModel):
309
+ model_config = ConfigDict(extra="allow")
310
+ T: float
311
+ P: float
312
+ Pt: float | None = None
313
+ Tt: float | None = None
314
+ rho: float | None = None
315
+ h: float | None = None
316
+ s: float | None = None
317
+ mach: float | None = None
318
+ Y: list[float]
319
+ X: list[float] | None = None
320
+
321
+
322
+ class NodeResult(BaseModel):
323
+ model_config = ConfigDict(extra="allow")
324
+ state: StateResult
325
+ success: bool = True
326
+
327
+
328
+ class ElementResult(BaseModel):
329
+ model_config = ConfigDict(extra="allow")
330
+ m_dot: float
331
+ success: bool = True
332
+
333
+
334
+ class SolveResponse(BaseModel):
335
+ success: bool
336
+ message: str = ""
337
+ final_norm: float | None = None
338
+ node_results: dict[str, NodeResult] = {}
339
+ element_results: dict[str, ElementResult] = {}
340
+ edge_results: dict[str, dict] = {}
gui/backend/units.py ADDED
@@ -0,0 +1,135 @@
1
+ """Unit catalogue for result-export column headers.
2
+
3
+ Mirrors the labels/units of the frontend ``QUANTITY_CATALOGUE`` so the CSV
4
+ produced by ``/export`` is self-describing (``P [Pa]``, ``T [K]`` …).
5
+
6
+ Keep this module dependency-free; it is imported by ``main.py`` during the
7
+ CSV-streaming response.
8
+ """
9
+
10
+ import re
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Scalar quantity → unit mapping
14
+ # ---------------------------------------------------------------------------
15
+
16
+ UNIT_MAP: dict[str, str] = {
17
+ # --- Thermodynamic state ---
18
+ "T": "K",
19
+ "T_total": "K",
20
+ "T_in": "K",
21
+ "T_out": "K",
22
+ "Tt_in": "K",
23
+ "Tt_out": "K",
24
+ "T_aw": "K",
25
+ "T_hot": "K",
26
+ "T_interface": "K",
27
+ "P": "Pa",
28
+ "P_total": "Pa",
29
+ "P_in": "Pa",
30
+ "P_out": "Pa",
31
+ "Pt_in": "Pa",
32
+ "Pt_out": "Pa",
33
+ "dP": "Pa",
34
+ "dPt": "Pa",
35
+ "pr_total": "-",
36
+ "pr_static": "-",
37
+ # --- Flow ---
38
+ "m_dot": "kg/s",
39
+ "velocity": "m/s",
40
+ "v": "m/s",
41
+ "a": "m/s",
42
+ "mach": "-",
43
+ "mach_in": "-",
44
+ "mach_out": "-",
45
+ "mach_throat": "-",
46
+ "mach_loss_ref": "-",
47
+ # --- Node stagnation ---
48
+ "Tt": "K",
49
+ "Pt": "Pa",
50
+ # --- Transport ---
51
+ "mu": "Pa·s",
52
+ "k": "W/m/K",
53
+ "nu": "m²/s",
54
+ "Pr": "-",
55
+ "Re": "-",
56
+ "Dh": "m",
57
+ # --- Thermo per-mass (node/element state) ---
58
+ "rho": "kg/m³",
59
+ "h": "J/kg",
60
+ "s": "J/kg/K",
61
+ "u": "J/kg",
62
+ "cp": "J/kg/K",
63
+ "cv": "J/kg/K",
64
+ "gamma": "-",
65
+ "mw": "kg/mol",
66
+ # --- Thermo reference-state block (Phase 2, element inlet state) ---
67
+ "rho_ref": "kg/m³",
68
+ "h_ref": "J/kg",
69
+ "s_ref": "J/kg/K",
70
+ "u_ref": "J/kg",
71
+ "cp_ref": "J/kg/K",
72
+ "cv_ref": "J/kg/K",
73
+ "gamma_ref": "-",
74
+ "a_ref": "m/s",
75
+ "mw_ref": "kg/mol",
76
+ "mu_ref": "Pa·s",
77
+ "k_ref": "W/m/K",
78
+ "nu_ref": "m²/s",
79
+ "Pr_ref": "-",
80
+ # --- Heat transfer ---
81
+ "Nu": "-",
82
+ "htc": "W/m²/K",
83
+ "h_a": "W/m²/K",
84
+ "h_b": "W/m²/K",
85
+ "Q": "W",
86
+ # --- Element diagnostics ---
87
+ "xi": "-",
88
+ "theta": "-",
89
+ "zeta": "-",
90
+ "ratio": "-", # AreaChangeElement (renamed to area_ratio in Phase 2)
91
+ "area_ratio": "-", # Phase 2 name
92
+ "is_correlation": "-",
93
+ "f": "-",
94
+ "Cd": "-",
95
+ "phi": "-",
96
+ # --- Geometry ---
97
+ "area": "m²",
98
+ "diameter": "m",
99
+ "length": "m",
100
+ "roughness": "m",
101
+ }
102
+
103
+ # Composition keys render as ``Y[<label>] [-]``. ``<label>`` may be a species
104
+ # name (e.g. ``N2``, ``H2O``, ``CH4``) or a numeric index for forward
105
+ # compatibility with older exports.
106
+ _COMPOSITION_RE = re.compile(r"^[YX]\[[A-Za-z0-9_+\-()]+\]$")
107
+
108
+
109
+ def label_with_unit(column: str) -> str:
110
+ """Return ``'<column> [<unit>]'`` when a unit is known, else ``column``.
111
+
112
+ Meta columns such as ``id``, ``type``, ``label``, ``kind``, ``success``
113
+ and ``is_boundary`` are passed through unchanged.
114
+ """
115
+ if column in _META_COLUMNS:
116
+ return column
117
+ if column in UNIT_MAP:
118
+ return f"{column} [{UNIT_MAP[column]}]"
119
+ if _COMPOSITION_RE.match(column):
120
+ return f"{column} [-]"
121
+ return column
122
+
123
+
124
+ _META_COLUMNS: frozenset[str] = frozenset(
125
+ {
126
+ "id",
127
+ "label",
128
+ "type",
129
+ "kind",
130
+ "success",
131
+ "is_boundary",
132
+ "combustion_method",
133
+ "ref_location",
134
+ }
135
+ )