ecopus commited on
Commit
86528f8
·
verified ·
1 Parent(s): 629b059

Initial/updated GUI deployment

Browse files
Files changed (5) hide show
  1. README.md +22 -12
  2. app.py +10 -0
  3. app_gradio_wing_selector.py +590 -0
  4. requirements.txt +6 -0
  5. runtime.txt +1 -0
README.md CHANGED
@@ -1,12 +1,22 @@
1
- ---
2
- title: Wing Selector Gui
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 5.49.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Wing Selector GUI
3
+ emoji: 🛩️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Wing Selector (Gradio)
12
+
13
+ Upload a novel airfoil `.dat/.txt` and (optionally) its polar file.
14
+ Pick an objective (**min_cd**, **max_cl**, **max_ld**).
15
+ The app generates candidate planforms and returns the best one with:
16
+ - static PNG,
17
+ - **interactive 3D** mesh (Plotly),
18
+ - STL export,
19
+ - JSON summary.
20
+
21
+ > If your model repo on the Hub is **private**, add a Space secret named **`HF_TOKEN`**
22
+ > so the app can download it at runtime.
app.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from app_gradio_wing_selector import demo, HF_TOKEN
3
+
4
+ # If your model repo is private, define a Space secret named HF_TOKEN.
5
+ # This will be available here automatically via env var on Spaces.
6
+ if HF_TOKEN and not os.getenv("HF_TOKEN"):
7
+ os.environ["HF_TOKEN"] = HF_TOKEN
8
+
9
+ # Launch the app; Spaces injects proper port/env.
10
+ demo.queue().launch()
app_gradio_wing_selector.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app_gradio_wing_selector.py
2
+ # ----------------------------------------------------------------------
3
+ # Gradio app that:
4
+ # • Downloads your trained selector model from Hugging Face Hub
5
+ # • Lets a user upload a novel airfoil perimeter and (optionally) a polar file
6
+ # • Lets the user pick an objective: min_cd / max_cl / max_ld
7
+ # • Generates a candidate set of wings (planform + twist)
8
+ # • Scores candidates with the selector and returns the best
9
+ # • Renders a static PNG, an interactive Plotly 3D mesh, and exports an ASCII STL
10
+ # • Returns a JSON summary + placeholder LLM explanation slot
11
+ #
12
+ # Run directly in VS Code (no shell commands needed).
13
+ # ----------------------------------------------------------------------
14
+
15
+ import os, sys, io, math, json, importlib, subprocess, tempfile
16
+ from typing import Tuple, Dict, List, Optional
17
+
18
+ # ========================= USER CONFIG =========================
19
+ MODEL_REPO_ID = "ecopus/wing-selector-mlp" # <-- your Hub model repo
20
+ HF_TOKEN = None # paste token here if the model is private; else leave None for public
21
+ APP_TITLE = "Transport Wing Selector"
22
+ APP_DESC = "Upload a novel airfoil & polar. Choose an objective. Get the best wing + PNG/STL + interactive 3D."
23
+ N_CANDIDATES = 160 # number of candidate wings to generate & score
24
+ SEED = 42 # RNG for reproducibility
25
+ N_STATIONS = 20 # spanwise stations (must match training)
26
+ PERIM_POINTS = 256 # perimeter points for smooth loft (resampled)
27
+ # Candidate ranges (SI units; meters & degrees)
28
+ HALFSPAN_MIN_M = 1.524 # 60 in
29
+ HALFSPAN_MAX_M = 3.048 # 120 in
30
+ ROOT_CHORD_MIN_M= 0.4572 # 18 in
31
+ ROOT_CHORD_MAX_M= 0.9144 # 36 in
32
+ TAPER_MIN = 0.25
33
+ TAPER_MAX = 0.50
34
+ TWIST_ROOT_MIN = 0.0 # deg
35
+ TWIST_ROOT_MAX = 2.0 # deg
36
+ TWIST_TIP_MIN = -6.0 # deg
37
+ TWIST_TIP_MAX = -2.0 # deg
38
+ # ===============================================================
39
+
40
+ # ---------------------- Dependency bootstrap ----------------------
41
+ def _need(pkg: str) -> bool:
42
+ try:
43
+ importlib.import_module(pkg)
44
+ return False
45
+ except Exception:
46
+ return True
47
+
48
+ def _pip_install(*pkgs: str, index_url: Optional[str] = None):
49
+ cmd = [sys.executable, "-m", "pip", "install", *pkgs]
50
+ if index_url:
51
+ cmd += ["--index-url", index_url]
52
+ subprocess.check_call(cmd)
53
+
54
+ def ensure_deps():
55
+ base = []
56
+ for p in ("numpy", "matplotlib", "gradio", "huggingface_hub", "plotly", "torch"):
57
+ if _need(p): base.append(p)
58
+ if base:
59
+ # Use CPU wheel for torch by default
60
+ if "torch" in base:
61
+ base.remove("torch")
62
+ if base:
63
+ _pip_install(*base)
64
+ idx = os.environ.get("TORCH_INDEX", "https://download.pytorch.org/whl/cpu")
65
+ _pip_install("torch", index_url=idx)
66
+ else:
67
+ _pip_install(*base)
68
+
69
+ try:
70
+ ensure_deps()
71
+ except Exception as _e:
72
+ print("[WARN] Dependency install encountered an issue:", _e)
73
+
74
+ import numpy as np
75
+ import torch, torch.nn as nn
76
+ from huggingface_hub import snapshot_download
77
+ import matplotlib
78
+ matplotlib.use("Agg")
79
+ import matplotlib.pyplot as plt
80
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401
81
+ import gradio as gr
82
+ import plotly.graph_objects as go
83
+
84
+ # --------------------------- Model defs ---------------------------
85
+ OBJECTIVES = ["min_cd", "max_cl", "max_ld"]
86
+
87
+ class MLPSelector(nn.Module):
88
+ def __init__(self, in_dim:int, n_airfoils:int, obj_dim:int=3, af_embed_dim:int=8, hidden:int=128):
89
+ super().__init__()
90
+ self.af_emb = nn.Embedding(n_airfoils, af_embed_dim)
91
+ self.net = nn.Sequential(
92
+ nn.Linear(in_dim + obj_dim + af_embed_dim, hidden),
93
+ nn.ReLU(),
94
+ nn.Linear(hidden, hidden),
95
+ nn.ReLU(),
96
+ nn.Linear(hidden, 1),
97
+ )
98
+ def forward(self, x, obj_id, af_id):
99
+ B = x.size(0)
100
+ obj_oh = torch.zeros(B, 3, device=x.device)
101
+ obj_oh[torch.arange(B), obj_id] = 1.0
102
+ af_e = self.af_emb(af_id)
103
+ z = torch.cat([x, obj_oh, af_e], dim=1)
104
+ return self.net(z).squeeze(1)
105
+
106
+ def load_selector_from_hub(repo_id: str, token: Optional[str] = None, device="cpu"):
107
+ cache_dir = snapshot_download(
108
+ repo_id=repo_id,
109
+ repo_type="model",
110
+ allow_patterns=["best.pt","last.pt","config.json","feature_names.json","airfoil_vocab.json"],
111
+ token=token
112
+ )
113
+ ckpt_path = os.path.join(cache_dir, "best.pt")
114
+ if not os.path.exists(ckpt_path):
115
+ ckpt_path = os.path.join(cache_dir, "last.pt")
116
+ if not os.path.exists(ckpt_path):
117
+ raise FileNotFoundError("best.pt/last.pt not found in the model repo")
118
+
119
+ with open(os.path.join(cache_dir, "config.json"), "r", encoding="utf-8") as f:
120
+ cfg = json.load(f)
121
+ feat_names = None
122
+ fn_path = os.path.join(cache_dir, "feature_names.json")
123
+ if os.path.exists(fn_path):
124
+ with open(fn_path, "r", encoding="utf-8") as f:
125
+ feat_names = json.load(f)
126
+ vocab = {}
127
+ vpath = os.path.join(cache_dir, "airfoil_vocab.json")
128
+ if os.path.exists(vpath):
129
+ with open(vpath, "r", encoding="utf-8") as f:
130
+ vocab = json.load(f)
131
+
132
+ ckpt = torch.load(ckpt_path, map_location=device)
133
+ in_dim = int(cfg.get("in_dim", ckpt.get("in_dim")))
134
+ n_airfoils = int(cfg.get("n_airfoils", ckpt.get("n_airfoils")))
135
+ means = np.array(cfg["feat_stats"]["means"], dtype=np.float32)
136
+ stds = np.array(cfg["feat_stats"]["stds"], dtype=np.float32)
137
+
138
+ model = MLPSelector(in_dim, n_airfoils)
139
+ model.load_state_dict(ckpt["model"])
140
+ model.to(device).eval()
141
+
142
+ return {"model": model, "means": means, "stds": stds, "feat_names": feat_names, "vocab": vocab, "cache_dir": cache_dir}
143
+
144
+ def standardize(X_raw: np.ndarray, means: np.ndarray, stds: np.ndarray) -> np.ndarray:
145
+ X_imp = np.where(np.isfinite(X_raw), X_raw, means)
146
+ return (X_imp - means) / np.where(stds==0, 1.0, stds)
147
+
148
+ # ---------------------- File parsing helpers ----------------------
149
+ def _read_file_bytes(file_input):
150
+ """
151
+ Accepts a Gradio File input that may be:
152
+ - a path string, OR
153
+ - a tempfile-like object with a .name attribute.
154
+ Returns file bytes, or None if file_input is None or invalid.
155
+ """
156
+ if file_input is None:
157
+ return None
158
+ if isinstance(file_input, (str, bytes, os.PathLike)):
159
+ path = str(file_input)
160
+ else:
161
+ path = getattr(file_input, "name", None)
162
+ if not path or not os.path.exists(path):
163
+ return None
164
+ with open(path, "rb") as f:
165
+ return f.read()
166
+
167
+ def parse_airfoil_file(fobj: io.BytesIO) -> Tuple[np.ndarray, np.ndarray]:
168
+ """Reads a UIUC-style .dat/.txt: two columns x y (can include headers)."""
169
+ raw = fobj.read().decode("utf-8", errors="ignore").strip().splitlines()
170
+ xs, ys = [], []
171
+ for line in raw:
172
+ line = line.strip()
173
+ if not line or line.startswith("#") or line.lower().startswith("airfoil"):
174
+ continue
175
+ line = line.replace(",", " ")
176
+ parts = [p for p in line.split() if p]
177
+ if len(parts) < 2:
178
+ continue
179
+ try:
180
+ x = float(parts[0]); y = float(parts[1])
181
+ except Exception:
182
+ continue
183
+ xs.append(x); ys.append(y)
184
+ xb = np.array(xs, dtype=float); yb = np.array(ys, dtype=float)
185
+ if xb.size < 10:
186
+ raise ValueError("Airfoil file has too few valid points.")
187
+ # Normalize x to [0,1]
188
+ xmin, xmax = float(xb.min()), float(xb.max())
189
+ if xmax - xmin > 0:
190
+ xb = (xb - xmin) / (xmax - xmin)
191
+ # Rotate so we start near trailing edge (x ~ 1)
192
+ i0 = int(np.argmax(xb))
193
+ xb = np.roll(xb, -i0); yb = np.roll(yb, -i0)
194
+ # Ensure closed loop
195
+ if not (np.isclose(xb[0], xb[-1]) and np.isclose(yb[0], yb[-1])):
196
+ xb = np.concatenate([xb, xb[:1]]); yb = np.concatenate([yb, yb[:1]])
197
+ return xb, yb
198
+
199
+ def resample_closed_perimeter(xb: np.ndarray, yb: np.ndarray, n: int = 256) -> Tuple[np.ndarray, np.ndarray]:
200
+ """Arc-length resample a closed loop to n points."""
201
+ xy = np.stack([xb, yb], axis=1)
202
+ dif = np.diff(xy, axis=0, append=xy[:1])
203
+ seg = np.linalg.norm(dif, axis=1)
204
+ s = np.concatenate([[0], np.cumsum(seg)]) # length M+1
205
+ s = s[:-1]
206
+ total = s[-1] + seg[-1]
207
+ t = np.linspace(0, total, n, endpoint=False)
208
+ xi = np.interp(t % total, s, xb)
209
+ yi = np.interp(t % total, s, yb)
210
+ return xi, yi
211
+
212
+ def parse_polar_file(fobj: Optional[io.BytesIO]) -> Dict[str, float]:
213
+ """Reads QBlade/XFOIL polar: columns alpha Cl Cd [Cm]. Returns summary metrics."""
214
+ if fobj is None:
215
+ return dict(cl_max=np.nan, cd_min=np.nan, ld_max=np.nan, cla_per_rad=np.nan, alpha0l_deg=np.nan)
216
+ raw = fobj.read().decode("utf-8", errors="ignore").strip().splitlines()
217
+ rows = []
218
+ for line in raw:
219
+ line = line.strip()
220
+ if not line or line.startswith("#") or line.lower().startswith("alpha"):
221
+ continue
222
+ parts = [p for p in line.replace(",", " ").split() if p]
223
+ nums = []
224
+ for p in parts[:4]:
225
+ try: nums.append(float(p))
226
+ except: pass
227
+ if len(nums) >= 3:
228
+ rows.append(nums[:4]) # alpha, Cl, Cd, [Cm]
229
+ if not rows:
230
+ return dict(cl_max=np.nan, cd_min=np.nan, ld_max=np.nan, cla_per_rad=np.nan, alpha0l_deg=np.nan)
231
+ A = np.array(rows, dtype=float)
232
+ alpha = A[:,0]; Cl = A[:,1]; Cd = A[:,2]
233
+ # dedupe/sort by alpha
234
+ uniq, idx = np.unique(alpha, return_index=True)
235
+ alpha = alpha[idx]; Cl = Cl[idx]; Cd = Cd[idx]
236
+ order = np.argsort(alpha)
237
+ alpha = alpha[order]; Cl = Cl[order]; Cd = Cd[order]
238
+ # summaries
239
+ with np.errstate(divide="ignore", invalid="ignore"):
240
+ ld = Cl / Cd
241
+ cl_max = np.nanmax(Cl) if Cl.size else np.nan
242
+ cd_min = np.nanmin(Cd) if Cd.size else np.nan
243
+ ld_max = np.nanmax(ld) if ld.size else np.nan
244
+ # linear fit near 0 deg
245
+ mask = (alpha >= -5.0) & (alpha <= 5.0)
246
+ if np.sum(mask) >= 3:
247
+ a = alpha[mask]; c = Cl[mask]
248
+ m, b = np.polyfit(a, c, 1) # Cl ≈ m*alpha_deg + b
249
+ cla_per_rad = m * (180.0 / math.pi)
250
+ alpha0l_deg = -b / m if m != 0 else np.nan
251
+ else:
252
+ cla_per_rad = np.nan; alpha0l_deg = np.nan
253
+ return dict(cl_max=float(cl_max), cd_min=float(cd_min), ld_max=float(ld_max),
254
+ cla_per_rad=float(cla_per_rad), alpha0l_deg=float(alpha0l_deg))
255
+
256
+ # ------------------------ Geometry generators ------------------------
257
+ rng = np.random.default_rng(SEED)
258
+
259
+ def schrenk_chord(y: np.ndarray, s: float, c_root: float, c_tip: float) -> np.ndarray:
260
+ c_trap = c_root + (c_tip - c_root) * (y / s)
261
+ c_ell = c_root * np.sqrt(np.maximum(0.0, 1.0 - (y / s)**2))
262
+ c = 0.5 * (c_trap + c_ell)
263
+ clamp = 0.25 * np.min(c_trap)
264
+ return np.maximum(c, clamp)
265
+
266
+ def planform_sample(n: int) -> List[Dict]:
267
+ out = []
268
+ for _ in range(n):
269
+ s = float(rng.uniform(HALFSPAN_MIN_M, HALFSPAN_MAX_M))
270
+ cr = float(rng.uniform(ROOT_CHORD_MIN_M, ROOT_CHORD_MAX_M))
271
+ lam = float(rng.uniform(TAPER_MIN, TAPER_MAX))
272
+ ct = lam * cr
273
+ i_root = float(rng.uniform(TWIST_ROOT_MIN, TWIST_ROOT_MAX))
274
+ i_tip = float(rng.uniform(TWIST_TIP_MIN, TWIST_TIP_MAX))
275
+ y = np.linspace(0.0, s, N_STATIONS)
276
+ c = schrenk_chord(y, s, cr, ct)
277
+ twist = i_root + (i_tip - i_root) * (y / s)
278
+ twist[0] = 0.0 # hinge at root
279
+ out.append(dict(s=s, c_root=cr, c_tip=ct, taper=lam, y=y, cvec=c, twist=twist))
280
+ return out
281
+
282
+ def planform_metrics(y: np.ndarray, c: np.ndarray, s: float) -> Dict[str, float]:
283
+ area_half = float(np.trapz(c, y)) # m^2
284
+ area_full = 2.0 * area_half # m^2
285
+ b_full = 2.0 * s # m
286
+ ar = (b_full**2) / area_full
287
+ c2_int_half = float(np.trapz(c**2, y))
288
+ MAC = (4.0 / area_full) * c2_int_half # m
289
+ return dict(area_m2=area_full, aspect_ratio=ar, mac_m=MAC, span_m=b_full)
290
+
291
+ def extract_features_for_candidate(pl: Dict, polar: Dict) -> np.ndarray:
292
+ span_m = 2.0 * pl["s"]
293
+ root_chord_m = pl["c_root"]
294
+ tip_chord_m = pl["c_tip"]
295
+ taper = pl["taper"]
296
+ mets = planform_metrics(pl["y"], pl["cvec"], pl["s"])
297
+ area_m2 = mets["area_m2"]; aspect_ratio = mets["aspect_ratio"]; mac_m = mets["mac_m"]
298
+ chord = pl["cvec"]
299
+ chord_mean = float(np.nanmean(chord)); chord_std = float(np.nanstd(chord))
300
+ def pick(arr, frac):
301
+ idx = int(round((arr.size-1)*frac)); return float(arr[idx])
302
+ chord_mid = pick(chord, 0.5); chord_q1 = pick(chord, 0.25); chord_q3 = pick(chord, 0.75)
303
+ twist = pl["twist"]
304
+ twist_mean = float(np.nanmean(twist)); twist_std = float(np.nanstd(twist))
305
+ washout_deg = float(twist[-1] - twist[0])
306
+ cl_max = float(polar["cl_max"])
307
+ cd_min = float(polar["cd_min"])
308
+ ld_max = float(polar["ld_max"])
309
+ cla_per_rad = float(polar["cla_per_rad"])
310
+ alpha0l_deg = float(polar["alpha0l_deg"])
311
+ has_polar = 1.0 if np.isfinite([cl_max,cd_min,ld_max,cla_per_rad,alpha0l_deg]).any() else 0.0
312
+
313
+ vec = np.array([
314
+ span_m, root_chord_m, tip_chord_m, taper, area_m2, aspect_ratio, mac_m,
315
+ chord_mean, chord_std, chord_mid, chord_q1, chord_q3,
316
+ twist_mean, twist_std, washout_deg,
317
+ cl_max, cd_min, ld_max, cla_per_rad, alpha0l_deg,
318
+ has_polar
319
+ ], dtype=float)
320
+ return vec
321
+
322
+ # --------------------------- Rendering ---------------------------
323
+ def loft_section_loops(dis_m: np.ndarray, chord_m: np.ndarray, twist_deg: np.ndarray,
324
+ xbar: np.ndarray, ybar: np.ndarray):
325
+ S_all, Y_all, Z_all = [], [], []
326
+ for j in range(dis_m.size):
327
+ c = chord_m[j]; th = math.radians(float(twist_deg[j]))
328
+ xc = (xbar - 0.25) * c
329
+ yc = ybar * c
330
+ Y = math.cos(th)*xc - math.sin(th)*yc
331
+ Z = math.sin(th)*xc + math.cos(th)*yc
332
+ S = np.full_like(Y, dis_m[j])
333
+ S_all.append(S); Y_all.append(Y); Z_all.append(Z)
334
+ return S_all, Y_all, Z_all
335
+
336
+ def _mesh_vertices_faces(S_all, Y_all, Z_all):
337
+ """Recreate vertex/face arrays (same logic as STL export)."""
338
+ nst = len(S_all)
339
+ if nst < 2: raise ValueError("Need at least 2 stations to mesh.")
340
+ n_perim = [len(a) for a in S_all]
341
+ if len(set(n_perim)) != 1:
342
+ raise ValueError(f"Perimeter sizes differ: {n_perim}")
343
+ M = n_perim[0]
344
+ closed = (
345
+ np.isclose(S_all[0][0], S_all[0][-1]) and
346
+ np.isclose(Y_all[0][0], Y_all[0][-1]) and
347
+ np.isclose(Z_all[0][0], Z_all[0][-1])
348
+ )
349
+ Meff = M - 1 if closed else M
350
+ S = np.vstack([np.asarray(S_all[j][:Meff], dtype=float) for j in range(nst)])
351
+ Y = np.vstack([np.asarray(Y_all[j][:Meff], dtype=float) for j in range(nst)])
352
+ Z = np.vstack([np.asarray(Z_all[j][:Meff], dtype=float) for j in range(nst)])
353
+ V = np.column_stack([S.reshape(-1), Y.reshape(-1), Z.reshape(-1)])
354
+
355
+ def vid(j, k): return j * Meff + k
356
+ faces = []
357
+ for j in range(nst - 1):
358
+ for k in range(Meff):
359
+ k2 = (k + 1) % Meff
360
+ v00 = vid(j, k); v01 = vid(j, k2)
361
+ v10 = vid(j+1, k); v11 = vid(j+1, k2)
362
+ faces.append((v00, v10, v11))
363
+ faces.append((v00, v11, v01))
364
+
365
+ # Add caps
366
+ root_center = np.array([S[0].mean(), Y[0].mean(), Z[0].mean()], dtype=float)
367
+ tip_center = np.array([S[-1].mean(), Y[-1].mean(), Z[-1].mean()], dtype=float)
368
+ rc_idx = len(V); tc_idx = len(V) + 1
369
+ V = np.vstack([V, root_center, tip_center])
370
+ for k in range(Meff):
371
+ k2 = (k + 1) % Meff
372
+ faces.append((rc_idx, vid(0, k2), vid(0, k)))
373
+ faces.append((tc_idx, vid(nst-1, k), vid(nst-1, k2)))
374
+ return V, faces
375
+
376
+ def render_png(S_all, Y_all, Z_all, pl: Dict, objective: str, out_png: str):
377
+ fig = plt.figure(figsize=(7.5, 5.5), dpi=140)
378
+ ax = fig.add_subplot(111, projection="3d")
379
+ for j in range(len(S_all)):
380
+ ax.plot(S_all[j], Y_all[j], Z_all[j], linewidth=0.8)
381
+ spokes = np.linspace(0, len(S_all[0])-1, 12, dtype=int)
382
+ for m in spokes:
383
+ ax.plot([S_all[j][m] for j in range(len(S_all))],
384
+ [Y_all[j][m] for j in range(len(S_all))],
385
+ [Z_all[j][m] for j in range(len(S_all))],
386
+ linewidth=0.6, alpha=0.8)
387
+ ax.set_xlabel("Span S (m)"); ax.set_ylabel("Y (m)"); ax.set_zlabel("Z (m)")
388
+ ax.view_init(elev=20, azim=35)
389
+ smin, smax = float(np.min([S.min() for S in S_all])), float(np.max([S.max() for S in S_all]))
390
+ ymin, ymax = float(np.min([Y.min() for Y in Y_all])), float(np.max([Y.max() for Y in Y_all]))
391
+ zmin, zmax = float(np.min([Z.min() for Z in Z_all])), float(np.max([Z.max() for Z in Z_all]))
392
+ sx = smax - smin; sy = ymax - ymin; sz = zmax - zmin
393
+ r = max(sx, sy, sz) * 0.6
394
+ sc = (smin+smax)/2; yc_ = (ymin+ymax)/2; zc = (zmin+zmax)/2
395
+ ax.set_xlim(sc-r, sc+r); ax.set_ylim(yc_-r, yc_+r); ax.set_zlim(zc-r, zc+r)
396
+ title = f"{objective} | span={2*pl['s']:.2f} m, c_root={pl['c_root']:.2f} m, taper={pl['taper']:.2f}"
397
+ ax.set_title(title, fontsize=9)
398
+ os.makedirs(os.path.dirname(out_png), exist_ok=True)
399
+ plt.tight_layout(); plt.savefig(out_png, bbox_inches="tight"); plt.close(fig)
400
+
401
+ def make_interactive_plot(S_all, Y_all, Z_all, pl: Dict, objective: str):
402
+ V, faces = _mesh_vertices_faces(S_all, Y_all, Z_all)
403
+ i = [f[0] for f in faces]; j = [f[1] for f in faces]; k = [f[2] for f in faces]
404
+
405
+ fig = go.Figure()
406
+
407
+ # Solid wing mesh
408
+ fig.add_trace(go.Mesh3d(
409
+ x=V[:,0], y=V[:,1], z=V[:,2],
410
+ i=i, j=j, k=k,
411
+ color="lightblue", opacity=1.0, flatshading=True, name="wing"
412
+ ))
413
+
414
+ # Light section polylines for visual cues
415
+ for jst in range(len(S_all)):
416
+ fig.add_trace(go.Scatter3d(
417
+ x=S_all[jst], y=Y_all[jst], z=Z_all[jst],
418
+ mode="lines", line=dict(width=2, color="gray"),
419
+ opacity=0.35, showlegend=False
420
+ ))
421
+
422
+ # Axes & camera
423
+ fig.update_scenes(
424
+ xaxis_title="Span S (m)", yaxis_title="Y (m)", zaxis_title="Z (m)",
425
+ aspectmode="data",
426
+ )
427
+ fig.update_layout(
428
+ title=f"{objective} | span={2*pl['s']:.2f} m, c_root={pl['c_root']:.2f} m, taper={pl['taper']:.2f}",
429
+ margin=dict(l=0, r=0, t=30, b=0),
430
+ scene_camera=dict(eye=dict(x=1.6, y=1.4, z=1.2))
431
+ )
432
+ return fig
433
+
434
+ def export_loft_to_stl(S_all, Y_all, Z_all, stl_path, solid_name="wing"):
435
+ os.makedirs(os.path.dirname(stl_path), exist_ok=True)
436
+ V, faces = _mesh_vertices_faces(S_all, Y_all, Z_all)
437
+
438
+ def tri_normal(p0, p1, p2):
439
+ n = np.cross(p1 - p0, p2 - p0); L = np.linalg.norm(n)
440
+ return (n / L) if L > 0 else np.array([0.0, 0.0, 0.0])
441
+
442
+ with open(stl_path, "w", encoding="utf-8") as f:
443
+ f.write(f"solid {solid_name}\n")
444
+ for (i0, i1, i2) in faces:
445
+ p0, p1, p2 = V[i0], V[i1], V[i2]
446
+ nx, ny, nz = tri_normal(p0, p1, p2)
447
+ f.write(f" facet normal {nx:.6e} {ny:.6e} {nz:.6e}\n")
448
+ f.write(" outer loop\n")
449
+ f.write(f" vertex {p0[0]:.6e} {p0[1]:.6e} {p0[2]:.6e}\n")
450
+ f.write(f" vertex {p1[0]:.6e} {p1[1]:.6e} {p1[2]:.6e}\n")
451
+ f.write(f" vertex {p2[0]:.6e} {p2[1]:.6e} {p2[2]:.6e}\n")
452
+ f.write(" endloop\n")
453
+ f.write(" endfacet\n")
454
+ f.write(f"endsolid {solid_name}\n")
455
+
456
+ # ------------------------- Scoring logic -------------------------
457
+ def score_candidates(model_pack: Dict, feats: np.ndarray, objective: str) -> np.ndarray:
458
+ model = model_pack["model"]
459
+ means = model_pack["means"]; stds = model_pack["stds"]
460
+ X_std = standardize(feats, means, stds)
461
+ X = torch.tensor(X_std, dtype=torch.float32, device=next(model.parameters()).device)
462
+ obj_id = OBJECTIVES.index(objective)
463
+ obj_ids = torch.full((X.size(0),), obj_id, dtype=torch.long, device=X.device)
464
+ # Unknown novel airfoil -> use airfoil_id=0 (shared embedding). This is a limitation of the trained model.
465
+ af_ids = torch.zeros((X.size(0),), dtype=torch.long, device=X.device)
466
+ with torch.no_grad():
467
+ probs = torch.sigmoid(model(X, obj_ids, af_ids)).cpu().numpy()
468
+ return probs
469
+
470
+ # --------------------------- Gradio fn ---------------------------
471
+ def find_best_wing(airfoil_file, polar_file, objective):
472
+ try:
473
+ if HF_TOKEN and not os.getenv("HF_TOKEN"):
474
+ os.environ["HF_TOKEN"] = HF_TOKEN
475
+ device = "cpu"
476
+ mp = load_selector_from_hub(MODEL_REPO_ID, token=HF_TOKEN, device=device)
477
+
478
+ # Parse airfoil (required)
479
+ airfoil_bytes = _read_file_bytes(airfoil_file)
480
+ if airfoil_bytes is None:
481
+ return None, None, None, None, json.dumps({"error":"Please upload an airfoil .dat/.txt"}), "No explanation (LLM placeholder)."
482
+ xb, yb = parse_airfoil_file(io.BytesIO(airfoil_bytes))
483
+ xb, yb = resample_closed_perimeter(xb, yb, n=PERIM_POINTS)
484
+
485
+ # Parse polar (optional)
486
+ polar_metrics = dict(cl_max=np.nan, cd_min=np.nan, ld_max=np.nan, cla_per_rad=np.nan, alpha0l_deg=np.nan)
487
+ polar_bytes = _read_file_bytes(polar_file)
488
+ if polar_bytes is not None:
489
+ polar_metrics = parse_polar_file(io.BytesIO(polar_bytes))
490
+
491
+ # Generate candidates and features
492
+ plans = planform_sample(N_CANDIDATES)
493
+ feats = np.stack([extract_features_for_candidate(pl, polar_metrics) for pl in plans], axis=0)
494
+
495
+ # Score & select best
496
+ probs = score_candidates(mp, feats, objective)
497
+ k = int(np.argmax(probs))
498
+ pl = plans[k]
499
+ mets = planform_metrics(pl["y"], pl["cvec"], pl["s"])
500
+ dis_m = pl["y"]
501
+ chord_m = pl["cvec"]
502
+ twist_d = pl["twist"]
503
+
504
+ # Loft & render
505
+ S_all, Y_all, Z_all = loft_section_loops(dis_m, chord_m, twist_d, xb, yb)
506
+ work = tempfile.mkdtemp(prefix="wingui_")
507
+ png_path = os.path.join(work, f"best_{objective}.png")
508
+ stl_path = os.path.join(work, f"best_{objective}.stl")
509
+ json_path = os.path.join(work, f"best_{objective}.json")
510
+ render_png(S_all, Y_all, Z_all, pl, objective, png_path)
511
+ export_loft_to_stl(S_all, Y_all, Z_all, stl_path, solid_name=f"best_{objective}")
512
+ fig3d = make_interactive_plot(S_all, Y_all, Z_all, pl, objective)
513
+
514
+ # Summary JSON
515
+ summary = {
516
+ "objective": objective,
517
+ "selector_prob": float(probs[k]),
518
+ "half_span_m": float(pl["s"]),
519
+ "span_m": float(2.0*pl["s"]),
520
+ "root_chord_m": float(pl["c_root"]),
521
+ "tip_chord_m": float(pl["c_tip"]),
522
+ "taper": float(pl["taper"]),
523
+ "area_m2": float(mets["area_m2"]),
524
+ "aspect_ratio": float(mets["aspect_ratio"]),
525
+ "mac_m": float(mets["mac_m"]),
526
+ "twist_root_deg": float(pl["twist"][0]),
527
+ "twist_tip_deg": float(pl["twist"][-1]),
528
+ "polar_summaries_used": polar_metrics,
529
+ "notes": "Airfoil embedding set to id=0 for novel airfoils (model limitation).",
530
+ }
531
+ with open(json_path, "w", encoding="utf-8") as f:
532
+ json.dump(summary, f, indent=2)
533
+
534
+ explanation = (
535
+ "Explanation (placeholder): The selector evaluated a grid of candidate planforms using your airfoil. "
536
+ "It standardized features (span, area, MAC, taper, twist stats, and polar summaries) and predicted the "
537
+ f"likelihood of being best for objective '{objective}'. The returned wing maximized that score and is "
538
+ "rendered above. A future LLM can narrate the tradeoffs and geometry rationale."
539
+ )
540
+
541
+ # Return: PNG, Interactive Plotly, STL, JSON (file), pretty JSON (string), explanation
542
+ return png_path, fig3d, stl_path, json_path, json.dumps(summary, indent=2), explanation
543
+
544
+ except Exception as e:
545
+ err = {"error": str(e)}
546
+ return None, None, None, None, json.dumps(err), "No explanation (LLM placeholder)."
547
+
548
+ # --------------------------- Gradio UI ---------------------------
549
+ with gr.Blocks(title=APP_TITLE) as demo:
550
+ gr.Markdown(f"# {APP_TITLE}\n{APP_DESC}")
551
+
552
+ with gr.Row():
553
+ airfoil_input = gr.File(
554
+ label="Airfoil perimeter (.dat/.txt, two columns x y)",
555
+ file_types=[".dat", ".txt"],
556
+ file_count="single",
557
+ )
558
+ polar_input = gr.File(
559
+ label="Polar file (.dat/.txt with α Cl Cd [Cm]) (optional)",
560
+ file_types=[".dat", ".txt"],
561
+ file_count="single",
562
+ )
563
+
564
+ objective = gr.Dropdown(choices=OBJECTIVES, value="min_cd", label="Objective")
565
+
566
+ run_btn = gr.Button("Find Best Wing", variant="primary")
567
+
568
+ with gr.Row():
569
+ img_out = gr.Image(label="Static 3D Render (PNG)", type="filepath")
570
+ plot_out = gr.Plot(label="Interactive 3D (orbit/zoom)")
571
+
572
+ with gr.Row():
573
+ stl_out = gr.File(label="STL Export")
574
+ json_out= gr.File(label="Best Wing Summary (JSON)")
575
+
576
+ with gr.Row():
577
+ summary_pretty = gr.Code(label="Summary (pretty JSON)", language="json")
578
+ with gr.Row():
579
+ explanation_box = gr.Textbox(label="Model Explanation (LLM slot)", lines=5)
580
+
581
+ run_btn.click(
582
+ fn=find_best_wing,
583
+ inputs=[airfoil_input, polar_input, objective],
584
+ outputs=[img_out, plot_out, stl_out, json_out, summary_pretty, explanation_box]
585
+ )
586
+
587
+ if __name__ == "__main__":
588
+ if HF_TOKEN and not os.getenv("HF_TOKEN"):
589
+ os.environ["HF_TOKEN"] = HF_TOKEN
590
+ demo.launch(server_name="0.0.0.0", server_port=7860, inbrowser=True, share=True)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ numpy
3
+ matplotlib
4
+ plotly
5
+ huggingface_hub>=0.23.0
6
+ torch
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11