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.
- combaero_gui-0.2.0.dist-info/METADATA +73 -0
- combaero_gui-0.2.0.dist-info/RECORD +11 -0
- combaero_gui-0.2.0.dist-info/WHEEL +5 -0
- combaero_gui-0.2.0.dist-info/entry_points.txt +2 -0
- combaero_gui-0.2.0.dist-info/top_level.txt +1 -0
- gui/__init__.py +0 -0
- gui/backend/__init__.py +0 -0
- gui/backend/graph_builder.py +443 -0
- gui/backend/main.py +359 -0
- gui/backend/schemas.py +340 -0
- gui/backend/units.py +135 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: combaero-gui
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CombAero Network GUI — React/FastAPI desktop application
|
|
5
|
+
Author-email: thiemom <2641939+thiemom@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/thiemom/combaero
|
|
8
|
+
Project-URL: Repository, https://github.com/thiemom/combaero
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/thiemom/combaero/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/thiemom/combaero/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: aerodynamics,combustion,aerothermal,propulsion,gui,network
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: fastapi
|
|
21
|
+
Requires-Dist: uvicorn
|
|
22
|
+
Requires-Dist: pydantic
|
|
23
|
+
Requires-Dist: pandas
|
|
24
|
+
Requires-Dist: combaero~=0.2
|
|
25
|
+
|
|
26
|
+
# CombAero Network Designer GUI
|
|
27
|
+
|
|
28
|
+
**Interactive design and diagnostic environment for fluid-thermal networks.**
|
|
29
|
+
|
|
30
|
+
The CombAero GUI allows for rapid construction of complex aerospace networks using a drag-and-drop interface powered by React Flow and a high-performance C++ backend.
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### 1. Environment Setup
|
|
35
|
+
Ensure the GUI components are installed:
|
|
36
|
+
```bash
|
|
37
|
+
uv pip install -e ".[gui]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Launch
|
|
41
|
+
The GUI can be launched with a single command:
|
|
42
|
+
```bash
|
|
43
|
+
combaero-gui
|
|
44
|
+
```
|
|
45
|
+
This starts both the FastAPI backend and the Vite-based frontend.
|
|
46
|
+
|
|
47
|
+
## Key Workflows
|
|
48
|
+
|
|
49
|
+
- **Build**: Drag nodes (Plenums, Boundaries) and elements (Orifices, Channels) from the sidebar.
|
|
50
|
+
- **Connect**: Click and drag from node handles to element ports.
|
|
51
|
+
- **Solve**: Click the **Solve** button in the header. Successful solves turn node borders green.
|
|
52
|
+
- **Inspect**: Click on any node or element to view real-time diagnostics in the Inspector sidebar.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Agent Reference (UI Automation Map)
|
|
57
|
+
|
|
58
|
+
For agents with screen control (multimodal LLMs), the following selectors and IDs are provided to assist in navigation:
|
|
59
|
+
|
|
60
|
+
| UI Component | Selector / ID | Description |
|
|
61
|
+
| :--- | :--- | :--- |
|
|
62
|
+
| **Solve Button** | `#solve-btn` | Triggers the network solve. |
|
|
63
|
+
| **Inspector Sidebar** | `.inspector-panel` | Properties for selected node/element. |
|
|
64
|
+
| **Network Canvas** | `#rf-canvas` | The main React Flow workspace. |
|
|
65
|
+
| **Node (General)** | `.react-flow__node` | Any node on the canvas. |
|
|
66
|
+
| **Solved Indicator** | `.solved-status-ok` | Appears on nodes after a successful solve. |
|
|
67
|
+
| **Error Console** | `#error-output` | Displays solver convergence failures. |
|
|
68
|
+
|
|
69
|
+
> [!TIP]
|
|
70
|
+
> **Automation Hint**: To add a node via script, use the `gui.backend` API or generate a network JSON and load it via `File -> Import`.
|
|
71
|
+
|
|
72
|
+
## Technical Reference
|
|
73
|
+
For internal data structures and JSON schemas, see [docs/GUI_TECHNICAL.md](../docs/GUI_TECHNICAL.md).
|
|
@@ -0,0 +1,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 @@
|
|
|
1
|
+
gui
|
gui/__init__.py
ADDED
|
File without changes
|
gui/backend/__init__.py
ADDED
|
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
|
+
)
|