Spaces:
Sleeping
Sleeping
Initial/updated GUI deployment
Browse files- README.md +22 -12
- app.py +10 -0
- app_gradio_wing_selector.py +590 -0
- requirements.txt +6 -0
- runtime.txt +1 -0
README.md
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Wing Selector
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|