diff --git a/.gitignore b/.gitignore index 6a183f90..736a85c5 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,7 @@ packages/flexfoil-python/src/flexfoil/_static/ packages/flexfoil-python/dist/ packages/flexfoil-python/*.png packages/flexfoil-python/*.csv +flow360_case.user.log +surfaces.tar.gz +*.pvtu +*.vtu diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index 85ac836b..77d33877 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.2", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index 8784ed81..dd94da2d 100644 --- a/flexfoil-ui/src/components/panels/SolvePanel.tsx +++ b/flexfoil-ui/src/components/panels/SolvePanel.tsx @@ -17,6 +17,7 @@ import { runSweep, type SweepConfig, type SweepRunData } from '../../lib/sweepEn import type { PolarPoint, SweepAxis, SweepParam } from '../../types'; import type { RunInsert } from '../../lib/storageBackend'; import { parseSweepValues, formatSweepValues } from '../../lib/parseSweepValues'; +import { isLocalMode } from '../../lib/storageBackend'; type SolveOrCacheResult = { result: AnalysisResult | null; @@ -164,6 +165,16 @@ export function SolvePanel() { const polar = useMemo(() => lastSeries?.points ?? [], [lastSeries]); const isViscous = solverMode === 'viscous'; + const isRans = solverMode === 'rans'; + + // RANS state + const [ransResult, setRansResult] = useState<{ + cl: number; cd: number; cm: number; alpha: number; + converged: boolean; success: boolean; error?: string | null; + case_id?: string; cd_pressure?: number; cd_friction?: number; + } | null>(null); + const [ransStatus, setRansStatus] = useState(null); + const [ransJobId, setRansJobId] = useState(null); const serializePoints = useCallback((points: { x: number; y: number; s?: number; surface?: 'upper' | 'lower' }[]) => { return JSON.stringify(points.map((point) => ({ @@ -831,6 +842,81 @@ export function SolvePanel() { maxIterations, solverMode, geometryDesign.flaps, name, isViscous, upsertPolar, addRun, addRunBatch, jobDispatch, jobComplete, jobUpdate]); + // --------------- RANS analysis --------------- + + const runRansAnalysis = useCallback(async () => { + if (panels.length < 3) return; + setIsRunning(true); + setError(null); + setRansResult(null); + setRansStatus('Submitting...'); + + const coordsJson = JSON.stringify(panels.map((p) => ({ x: p.x, y: p.y }))); + + try { + const resp = await fetch('/api/rans/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + coordinates_json: coordsJson, + alpha: targetAlpha, + reynolds: reynolds, + mach: mach || 0.2, + airfoil_name: name, + }), + }); + const data = await resp.json(); + if (!resp.ok) { + setError(data.error || 'Failed to submit RANS case'); + setIsRunning(false); + setRansStatus(null); + return; + } + const jobId = data.job_id; + setRansJobId(jobId); + setRansStatus('Submitted — generating mesh...'); + + // Poll for completion + const pollInterval = setInterval(async () => { + try { + const statusResp = await fetch(`/api/rans/status/${jobId}`); + const statusData = await statusResp.json(); + const status = statusData.status; + + if (status === 'Generating mesh') setRansStatus('Generating mesh...'); + else if (status === 'Uploading mesh') setRansStatus('Uploading mesh to Flow360...'); + else if (status === 'Submitting case') setRansStatus('Submitting case...'); + else if (status === 'Running RANS solver' || status === 'running') setRansStatus('Solving (this may take a few minutes)...'); + else if (status === 'Fetching results') setRansStatus('Fetching results...'); + else if (status === 'complete' || status === 'failed') { + clearInterval(pollInterval); + setIsRunning(false); + if (statusData.result) { + setRansResult(statusData.result); + if (statusData.result.success) { + setRansStatus('Complete'); + } else { + setRansStatus(null); + setError(statusData.result.error || 'RANS case failed'); + } + } else { + setRansStatus(null); + setError('RANS case failed'); + } + } else { + setRansStatus(`${status}...`); + } + } catch { + // Silently retry + } + }, 3000); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to submit RANS case'); + setIsRunning(false); + setRansStatus(null); + } + }, [panels, targetAlpha, reynolds, mach, name]); + // --------------- derived --------------- const clAlpha = useMemo(() => { @@ -868,15 +954,27 @@ export function SolvePanel() { > Viscous + {isLocalMode() && ( + + )}
- {isViscous - ? 'XFOIL viscous solver with boundary layer coupling' - : 'Linear-vorticity panel method (CD = 0)'} + {isRans + ? 'RANS CFD via Flow360 (cloud compute)' + : isViscous + ? 'XFOIL viscous solver with boundary layer coupling' + : 'Linear-vorticity panel method (CD = 0)'}
- {isViscous && ( + {(isViscous || isRans) && (
Reynolds Number
)} + {isRans && ( +
+
Mach Number
+ setMach(Math.max(0.01, Math.min(0.9, parseFloat(e.target.value) || 0.2)))} + step={0.01} + min={0.01} + max={0.9} + /> +
+ Required for compressible RANS (SA turbulence model) +
+
+ )} + {isViscous && (
- -
+ {!isRans && ( +
+ + +
+ )}
{ const val = parseFloat(e.target.value); - if (runMode === 'alpha') setTargetAlpha(val); + if (isRans || runMode === 'alpha') setTargetAlpha(val); else setTargetCl(val); }} - step={runMode === 'alpha' ? 0.5 : 0.1} + step={isRans || runMode === 'alpha' ? 0.5 : 0.1} style={{ flex: 1 }} />
+ + {/* RANS progress indicator */} + {isRans && ransStatus && ( +
+ {isRunning && ( + + )} + {ransStatus} +
+ )}
- {/* Single Point Results */} - {result && result.success && ( + {/* Single Point Results — XFOIL */} + {!isRans && result && result.success && (
Results
)} + {/* Single Point Results — RANS */} + {isRans && ransResult && ransResult.success && ( +
+
RANS Results
+
+ + + +
+ {ransResult.cd_pressure != null && ransResult.cd_friction != null && ( +
+ + +
+ )} +
+ Flow360 RANS (SA) at α={ransResult.alpha.toFixed(1)}°, Re={ransResult.reynolds.toExponential(1)}, M={ransResult.mach.toFixed(2)} + {ransResult.case_id && ( + · Case: {ransResult.case_id.slice(0, 8)} + )} +
+
+ )} + {/* Parameter Sweep */}
Parameter Sweep
diff --git a/flexfoil-ui/src/types/index.ts b/flexfoil-ui/src/types/index.ts index 1ba33a00..39de4771 100644 --- a/flexfoil-ui/src/types/index.ts +++ b/flexfoil-ui/src/types/index.ts @@ -18,7 +18,7 @@ export interface AirfoilPoint extends Point { /** Control modes for airfoil manipulation */ export type ControlMode = 'parameters' | 'camber-spline' | 'thickness-spline' | 'inverse-design' | 'geometry-design'; -export type SolverMode = 'viscous' | 'inviscid'; +export type SolverMode = 'viscous' | 'inviscid' | 'rans'; export type RunMode = 'alpha' | 'cl'; export type AxisVariable = 'alpha' | 'cl' | 'cd' | 'cm' | 'ld' | 'reynolds' | 'mach' | 'ncrit' | 'flapDeflection' | 'flapHingeX'; diff --git a/packages/flexfoil-python/src/flexfoil/__init__.py b/packages/flexfoil-python/src/flexfoil/__init__.py index 5bc02ad3..ed01e199 100644 --- a/packages/flexfoil-python/src/flexfoil/__init__.py +++ b/packages/flexfoil-python/src/flexfoil/__init__.py @@ -16,6 +16,7 @@ from flexfoil.database import RunDatabase, get_database from flexfoil.polar import PolarResult + __all__ = [ "Airfoil", "BLResult", diff --git a/packages/flexfoil-python/src/flexfoil/server.py b/packages/flexfoil-python/src/flexfoil/server.py index 736846fe..302dff93 100644 --- a/packages/flexfoil-python/src/flexfoil/server.py +++ b/packages/flexfoil-python/src/flexfoil/server.py @@ -215,6 +215,122 @@ async def uiuc_proxy(request: Request) -> Response: return Response(text, media_type="text/plain") +# --------------------------------------------------------------------------- +# RANS endpoints — Flow360 cloud CFD +# --------------------------------------------------------------------------- + +_rans_jobs: dict[str, dict] = {} # case_id → {status, result, ...} + + +async def rans_submit(request: Request) -> JSONResponse: + """Submit a RANS case to Flow360 (runs in background).""" + try: + from flexfoil.rans.flow360 import check_auth, run_rans + except ImportError: + return JSONResponse( + {"error": "flow360client not installed. Run: pip install flexfoil[rans]"}, + status_code=501, + ) + + body = await request.json() + coords_json = body.get("coordinates_json", "[]") + coords = [(p["x"], p["y"]) for p in json.loads(coords_json)] + alpha = float(body.get("alpha", 0.0)) + Re = float(body.get("reynolds", 1e6)) + mach = float(body.get("mach", 0.2)) + airfoil_name = body.get("airfoil_name", "airfoil") + + if not check_auth(): + return JSONResponse( + {"error": "Flow360 credentials not configured"}, + status_code=401, + ) + + # Generate a tracking ID + import uuid + job_id = str(uuid.uuid4())[:8] + _rans_jobs[job_id] = {"status": "submitted", "result": None} + + async def _background(): + try: + async def progress(status, frac): + _rans_jobs[job_id]["status"] = status + await _broadcast("rans_status", { + "job_id": job_id, + "status": status, + "progress": frac, + }) + + # Run in thread pool (blocking I/O) + def _sync_progress(status, frac): + _rans_jobs[job_id]["status"] = status + + result = await asyncio.to_thread( + run_rans, + coords, + alpha=alpha, + Re=Re, + mach=mach, + airfoil_name=airfoil_name, + on_progress=_sync_progress, + ) + + _rans_jobs[job_id]["status"] = "complete" if result.success else "failed" + _rans_jobs[job_id]["result"] = { + "cl": result.cl, + "cd": result.cd, + "cm": result.cm, + "alpha": result.alpha, + "reynolds": result.reynolds, + "mach": result.mach, + "converged": result.converged, + "success": result.success, + "error": result.error, + "case_id": result.case_id, + "cd_pressure": result.cd_pressure, + "cd_friction": result.cd_friction, + } + + await _broadcast("rans_status", { + "job_id": job_id, + "status": _rans_jobs[job_id]["status"], + "progress": 1.0, + "result": _rans_jobs[job_id]["result"], + }) + + except Exception as e: + _rans_jobs[job_id]["status"] = "failed" + _rans_jobs[job_id]["result"] = {"error": str(e), "success": False} + await _broadcast("rans_status", { + "job_id": job_id, + "status": "failed", + "error": str(e), + }) + + asyncio.create_task(_background()) + return JSONResponse({"job_id": job_id, "status": "submitted"}, status_code=202) + + +async def rans_status(request: Request) -> JSONResponse: + """Get status of a RANS job.""" + job_id = request.path_params["job_id"] + job = _rans_jobs.get(job_id) + if job is None: + return JSONResponse({"error": "Job not found"}, status_code=404) + return JSONResponse({"job_id": job_id, **job}) + + +async def rans_result(request: Request) -> JSONResponse: + """Get result of a completed RANS job.""" + job_id = request.path_params["job_id"] + job = _rans_jobs.get(job_id) + if job is None: + return JSONResponse({"error": "Job not found"}, status_code=404) + if job["result"] is None: + return JSONResponse({"error": "Not complete yet", "status": job["status"]}, status_code=202) + return JSONResponse(job["result"]) + + # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- @@ -259,6 +375,9 @@ def _build_routes() -> list: Route("/api/db/export", export_db), Route("/api/db/import", import_db, methods=["POST"]), Route("/api/uiuc-proxy/{filename:path}", uiuc_proxy), + Route("/api/rans/submit", rans_submit, methods=["POST"]), + Route("/api/rans/status/{job_id:str}", rans_status), + Route("/api/rans/result/{job_id:str}", rans_result), Route("/api/events", sse_endpoint), ] if STATIC_DIR.is_dir():