Skip to content

Commit a87f8f6

Browse files
committed
feat: UIUC Selig database browser, random foil, Lednicer format support
- Add searchable Selig database panel with ~1,600 airfoils from UIUC - Random Foil button picks and loads a random airfoil from the catalog - Parse Lednicer-format .dat files (two-section upper/lower layout) - Proxy endpoints for CORS-free .dat fetching (Vite dev + Python server) - Attribution link to UIUC Airfoil Coordinates Database - Bundled catalog JSON generated from UIUC database HTML - Data Explorer enhancements, outlier flagging, Plot Builder improvements - Python API polar result aggregation methods Made-with: Cursor
1 parent 3248da8 commit a87f8f6

48 files changed

Lines changed: 9103 additions & 199 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs-site/docs/python-api.mdx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,19 @@ Polar sweeps are **parallelized by default** using all available CPU cores
5757
```python
5858
polar = foil.polar(alpha=(-5, 15, 0.5), Re=1e6)
5959
print(polar)
60-
# PolarResult('NACA 2412', Re=1e+06, 40/41 converged)
60+
# PolarResult('NACA 2412', Re=1e+06, 40/41 converged, CLmax=1.4523, α_stall=12.5°, L/D_max=78.3)
61+
62+
# Aggregate statistics
63+
polar.cl_max # 1.4523
64+
polar.alpha_stall # 12.5
65+
polar.ld_max # 78.3
66+
polar.cd_min # 0.00523
67+
polar.summary() # dict with all aggregate statistics
68+
69+
# Generic aggregation with optional outlier filtering
70+
polar.column_max('cl', filter_outliers=True)
71+
polar.argmax('ld', 'alpha') # alpha at max L/D
72+
polar.argmin('cd', 'alpha') # alpha at min CD
6173

6274
# Interactive plotly figure (default): CL-α, CD-α, CL-CD, CM-α
6375
polar.plot()
@@ -68,8 +80,8 @@ polar.plot(backend="matplotlib")
6880
# Sequential mode (for debugging or progress output)
6981
polar = foil.polar(alpha=(-5, 15, 0.5), Re=1e6, parallel=False)
7082

71-
# Export to pandas
72-
df = polar.to_dataframe()
83+
# Export to pandas (with summary statistics in df.attrs)
84+
df = polar.to_dataframe(summary=True)
7385
df.to_csv("polar.csv", index=False)
7486
```
7587

@@ -262,8 +274,23 @@ are byte-identical.
262274
| `.ld` | `list[float]` — lift-to-drag ratios |
263275
| `.converged` | `list[SolveResult]` — converged results only |
264276
| `.results` | `list[SolveResult]` — all results |
265-
| `.to_dict()` | Export as `dict` |
266-
| `.to_dataframe()` | Export as `pandas.DataFrame` |
277+
| `.cl_max` | `float \| None` — maximum CL |
278+
| `.cl_min` | `float \| None` — minimum CL |
279+
| `.cd_min` | `float \| None` — minimum CD |
280+
| `.ld_max` | `float \| None` — maximum L/D |
281+
| `.alpha_stall` | `float \| None` — alpha at CL_max |
282+
| `.alpha_at_ld_max` | `float \| None` — alpha at L/D_max |
283+
| `.alpha_at_cd_min` | `float \| None` — alpha at CD_min |
284+
| `.column_max(col, filter_outliers=False)` | Max of any column |
285+
| `.column_min(col, filter_outliers=False)` | Min of any column |
286+
| `.column_mean(col, filter_outliers=False)` | Mean of any column |
287+
| `.column_median(col, filter_outliers=False)` | Median of any column |
288+
| `.column_stdev(col, filter_outliers=False)` | Std deviation of any column |
289+
| `.argmax(target, return_col, filter_outliers=False)` | Value of *return_col* at max of *target* |
290+
| `.argmin(target, return_col, filter_outliers=False)` | Value of *return_col* at min of *target* |
291+
| `.summary()` | Dict of all aggregate statistics |
292+
| `.to_dict(summary=False)` | Export as `dict` (optionally include summary) |
293+
| `.to_dataframe(summary=False)` | Export as `pandas.DataFrame` (summary in `.attrs`) |
267294
| `.plot(show=True, backend="plotly")` | 4-panel figure (plotly default, or `"matplotlib"`) |
268295

269296
### RunDatabase

docs-site/docs/web-app.mdx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ The UI is organized as a set of dockable, rearrangeable panels.
2929

3030
### Airfoil Library
3131

32-
Browse and load airfoils from the NACA 4-digit generator and the Selig
33-
database (over 1,500 airfoils). Select any airfoil to load it into the canvas.
32+
Browse and load airfoils from the NACA 4-digit generator, import `.dat` files,
33+
or search the UIUC Selig database (~1,600 airfoils). Type at least two
34+
characters in the Selig search box to filter by name or filename, then click an
35+
entry to fetch and load it. The **Random Foil** button picks a random airfoil
36+
from the database and loads it instantly.
3437

3538
### Airfoil Canvas
3639

@@ -57,7 +60,8 @@ Visualize sweep results with configurable axes — CL, CD, CM, L/D, alpha,
5760
Reynolds, Mach, Ncrit, flap deflection, and flap hinge x/c. Overlay
5861
multiple series for direct comparison. Enable the **Outliers** checkbox to
5962
automatically remove statistical outliers (IQR method) from the displayed
60-
data.
63+
data. Right-click any data point to manually flag it as an outlier; flagged
64+
points appear as red X marks and are excluded when the outlier filter is on.
6165

6266
### Visualization
6367

@@ -76,13 +80,44 @@ filter, and search across airfoils, operating conditions, and results. Includes
7680
a SPLOM (scatter plot matrix) correlogram for multivariate exploration. Custom
7781
computed columns let you define algebraic expressions over existing fields. The
7882
correlogram view supports automatic outlier removal via the **Outliers** toggle.
83+
Right-click a scatter point to manually flag it as an outlier; flagged runs are
84+
persisted in the database and excluded from all plots. The table includes a
85+
filterable **Outlier** column.
86+
87+
**Row grouping and aggregation** — drag any column header (e.g. Airfoil, Re) into
88+
the row group panel above the grid. Grouped rows automatically aggregate their
89+
children: CL shows `max`, CD shows `min`, L/D shows `max`, etc. Change the
90+
aggregation function for any column via the sidebar (right-click a column header
91+
→ "Value Aggregation"). Custom aggregation functions include:
92+
93+
- **at max(CL)** / **at min(CD)** / **at max(L/D)** — returns the value of the
94+
current column at the row where the target column reaches its extremum.
95+
For example, applying "at max(CL)" to the alpha column gives **α_stall**.
96+
- **median** — median aggregation (not available by default in AG Grid).
97+
98+
Two predefined aerodynamic summary columns are available (hidden by default —
99+
show them via the sidebar):
100+
- **α_stall** — angle of attack at CL_max
101+
- **α @ L/D_max** — angle of attack at maximum lift-to-drag ratio
102+
103+
Use the **Converged Only** button to quickly filter out non-converged solver
104+
points before grouping, which is particularly useful near stall where the
105+
solver may not converge reliably.
79106

80107
### Plot Builder
81108

82109
Build custom charts from run data: scatter, line, bar, and histogram plots.
83110
Map any database field to axes, color, or size. Useful for parameter studies
84111
and trade-off visualization. Toggle **Outliers** to automatically exclude
85-
statistical outliers using the IQR method.
112+
statistical outliers using the IQR method. Right-click any plotted point to
113+
manually flag it as an outlier; flagged runs are persisted and excluded from
114+
the chart.
115+
116+
The **Aggregated** data source (available when row grouping is active in the
117+
Data Explorer) lets you plot group-level statistics directly. For example, to
118+
plot L/D_max vs Reynolds number for multiple airfoils: group by Airfoil and Re
119+
in the Data Explorer, then select Data → Aggregated, X → Re, Y → L/D in the
120+
Plot Builder.
86121

87122
### Case Logs
88123

e2e/helpers/tourRunner.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* tourRunner — Converts TourStep[] definitions into Playwright test actions.
3+
*
4+
* This is the bridge between in-app "Show Me" tutorials and E2E tests.
5+
* Both consume the same TourStep[] from onboarding/showMe/*.ts, ensuring
6+
* the tutorial script and the test script stay in sync.
7+
*
8+
* For each step the runner:
9+
* 1. Focuses the required panel (clicks its tab)
10+
* 2. Waits for the target element to be visible
11+
* 3. Executes the challenge action (if the step has a challengeId)
12+
* 4. Asserts the expected state after the action
13+
*/
14+
15+
import { type Page, expect } from '@playwright/test';
16+
17+
export interface TourStepLike {
18+
element?: string;
19+
focusPanel?: string;
20+
challengeId?: string;
21+
popover?: { title?: string; description?: string };
22+
}
23+
24+
/**
25+
* Maps challengeId -> a function that performs the user action in Playwright
26+
* and asserts the expected post-condition.
27+
*/
28+
type ChallengeAction = (page: Page) => Promise<void>;
29+
30+
const challengeActions: Record<string, ChallengeAction> = {
31+
'switch-to-gdes': async (page) => {
32+
await page.locator('[data-tour="control-mode-gdes"]').click();
33+
await expect(page.locator('[data-tour="control-mode-gdes"].active')).toBeVisible();
34+
},
35+
36+
'add-flap': async (page) => {
37+
await page.locator('[data-tour="gdes-add-flap"]').click();
38+
await expect(page.locator('[data-tour="gdes-flaps"]')).toContainText('Flap');
39+
},
40+
41+
'deflect-flap': async (page) => {
42+
const deflectionInput = page.locator('[data-tour="gdes-flaps"] input[type="number"]').first();
43+
await deflectionInput.fill('10');
44+
await deflectionInput.press('Enter');
45+
},
46+
47+
'generate-polar': async (page) => {
48+
const generateBtn = page.locator('[data-tour="solve-polar"] button', { hasText: /generate/i });
49+
await generateBtn.click();
50+
await page.waitForTimeout(2000);
51+
},
52+
53+
'run-sweep': async (page) => {
54+
const generateBtn = page.locator('[data-tour="solve-polar"] button', { hasText: /generate/i });
55+
await generateBtn.click();
56+
await page.waitForTimeout(1000);
57+
},
58+
59+
'set-alpha-10': async (page) => {
60+
const alphaInput = page.locator('[data-tour="solve-alpha"] input[type="number"]');
61+
await alphaInput.fill('10');
62+
await alphaInput.press('Enter');
63+
},
64+
65+
'change-airfoil': async (page) => {
66+
const nacaInput = page.locator('[data-tour="panel-library"] input').first();
67+
await nacaInput.fill('4412');
68+
const generateBtn = page.locator('[data-tour="panel-library"] button', { hasText: /generate/i });
69+
await generateBtn.click();
70+
await page.waitForTimeout(500);
71+
},
72+
73+
'add-computed-column': async (page) => {
74+
const addBtn = page.locator('[data-tour="de-column-chips"] button', { hasText: /add/i });
75+
if (await addBtn.isVisible()) {
76+
await addBtn.click();
77+
await page.waitForTimeout(500);
78+
}
79+
},
80+
81+
'open-visualization-panel': async (page) => {
82+
await focusPanelByMenu(page, 'Visualization');
83+
},
84+
85+
'open-solve-panel': async (page) => {
86+
await focusPanelByMenu(page, 'Solve');
87+
},
88+
89+
'enable-streamlines': async (page) => {
90+
const toggle = page.locator('[data-tour="viz-streamlines"] input[type="checkbox"]');
91+
if (!(await toggle.isChecked())) await toggle.click();
92+
},
93+
94+
'enable-psi': async (page) => {
95+
const toggle = page.locator('[data-tour="viz-psi"] input[type="checkbox"]');
96+
if (!(await toggle.isChecked())) await toggle.click();
97+
},
98+
99+
'enable-smoke': async (page) => {
100+
const toggle = page.locator('[data-tour="viz-smoke"] input[type="checkbox"]');
101+
if (!(await toggle.isChecked())) await toggle.click();
102+
},
103+
104+
'adjust-thickness': async (page) => {
105+
const slider = page.locator('[data-tour="thickness-slider"] input[type="range"]');
106+
await slider.fill('1.2');
107+
},
108+
109+
'adjust-camber': async (page) => {
110+
const slider = page.locator('[data-tour="camber-slider"] input[type="range"]');
111+
await slider.fill('1.3');
112+
},
113+
};
114+
115+
async function focusPanelByMenu(page: Page, panelName: string) {
116+
await page.locator('[data-tour="menu-window"]').click();
117+
await page.locator(`text=${panelName}`).click();
118+
await page.waitForTimeout(300);
119+
}
120+
121+
async function focusPanel(page: Page, panelId: string) {
122+
const tabButton = page.locator(`.flexlayout__tab_button`, { hasText: new RegExp(panelId, 'i') });
123+
if (await tabButton.isVisible()) {
124+
await tabButton.click();
125+
await page.waitForTimeout(200);
126+
return;
127+
}
128+
const panelNames: Record<string, string> = {
129+
'control': 'Control',
130+
'solve': 'Solve',
131+
'library': 'Library',
132+
'visualization': 'Visualization',
133+
'polar': 'Polar',
134+
'canvas': 'Canvas',
135+
'spacing': 'Spacing',
136+
'data-explorer': 'Data Explorer',
137+
'plot-builder': 'Plot Builder',
138+
'properties': 'Properties',
139+
};
140+
const name = panelNames[panelId] ?? panelId;
141+
await focusPanelByMenu(page, name);
142+
}
143+
144+
/**
145+
* Run a tour's steps as Playwright actions.
146+
*
147+
* @param page - Playwright Page object
148+
* @param steps - TourStep[] from a showMe micro-tour
149+
* @param options.skipChallenges - if true, only verify element visibility (no actions)
150+
*/
151+
export async function runTourSteps(
152+
page: Page,
153+
steps: TourStepLike[],
154+
options: { skipChallenges?: boolean } = {},
155+
) {
156+
for (const step of steps) {
157+
if (step.focusPanel) {
158+
await focusPanel(page, step.focusPanel);
159+
}
160+
161+
if (step.element) {
162+
const locator = page.locator(step.element).first();
163+
await expect(locator).toBeVisible({ timeout: 5000 });
164+
}
165+
166+
if (step.challengeId && !options.skipChallenges) {
167+
const action = challengeActions[step.challengeId];
168+
if (action) {
169+
await action(page);
170+
}
171+
}
172+
}
173+
}

e2e/showMe/dataAnalysis.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from '@playwright/test';
2+
import { runTourSteps } from '../helpers/tourRunner';
3+
import { dataAnalysisShowMe } from '../../flexfoil-ui/src/onboarding/showMe/dataAnalysis';
4+
5+
test.describe('Show Me: Enhanced Data Analysis', () => {
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/');
8+
await page.waitForSelector('[data-tour="panel-canvas"]', { timeout: 15000 });
9+
});
10+
11+
test('all tour elements are visible when their panels are focused', async ({ page }) => {
12+
await runTourSteps(page, dataAnalysisShowMe, { skipChallenges: true });
13+
});
14+
15+
test('full interactive walkthrough completes', async ({ page }) => {
16+
await runTourSteps(page, dataAnalysisShowMe);
17+
const correlogramTab = page.locator('[data-tour="de-tab-correlogram"]');
18+
await expect(correlogramTab).toBeVisible();
19+
});
20+
});

e2e/showMe/flapDesign.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from '@playwright/test';
2+
import { runTourSteps } from '../helpers/tourRunner';
3+
import { flapDesignShowMe } from '../../flexfoil-ui/src/onboarding/showMe/flapDesign';
4+
5+
test.describe('Show Me: Flap Design', () => {
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/');
8+
await page.waitForSelector('[data-tour="panel-canvas"]', { timeout: 15000 });
9+
});
10+
11+
test('all tour elements are visible when their panels are focused', async ({ page }) => {
12+
await runTourSteps(page, flapDesignShowMe, { skipChallenges: true });
13+
});
14+
15+
test('full interactive walkthrough completes', async ({ page }) => {
16+
await runTourSteps(page, flapDesignShowMe);
17+
const flaps = page.locator('[data-tour="gdes-flaps"]');
18+
await expect(flaps).toContainText('Flap');
19+
});
20+
});

e2e/showMe/multiSweep.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from '@playwright/test';
2+
import { runTourSteps } from '../helpers/tourRunner';
3+
import { multiSweepShowMe } from '../../flexfoil-ui/src/onboarding/showMe/multiSweep';
4+
5+
test.describe('Show Me: Multi-Parameter Sweeps', () => {
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/');
8+
await page.waitForSelector('[data-tour="panel-canvas"]', { timeout: 15000 });
9+
});
10+
11+
test('all tour elements are visible when their panels are focused', async ({ page }) => {
12+
await runTourSteps(page, multiSweepShowMe, { skipChallenges: true });
13+
});
14+
15+
test('full interactive walkthrough completes', async ({ page }) => {
16+
await runTourSteps(page, multiSweepShowMe);
17+
const polarPanel = page.locator('[data-tour="panel-polar"]');
18+
await expect(polarPanel).toBeVisible();
19+
});
20+
});

e2e/showMe/solverQueue.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from '@playwright/test';
2+
import { runTourSteps } from '../helpers/tourRunner';
3+
import { solverQueueShowMe } from '../../flexfoil-ui/src/onboarding/showMe/solverQueue';
4+
5+
test.describe('Show Me: Solver Queue & Status Bar', () => {
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/');
8+
await page.waitForSelector('[data-tour="panel-canvas"]', { timeout: 15000 });
9+
});
10+
11+
test('all tour elements are visible when their panels are focused', async ({ page }) => {
12+
await runTourSteps(page, solverQueueShowMe, { skipChallenges: true });
13+
});
14+
15+
test('full interactive walkthrough completes', async ({ page }) => {
16+
await runTourSteps(page, solverQueueShowMe);
17+
const statusIndicator = page.locator('[data-tour="solver-status"]');
18+
await expect(statusIndicator).toBeVisible();
19+
});
20+
});

0 commit comments

Comments
 (0)