diff --git a/CLAUDE.md b/CLAUDE.md index d33ad166..eaebcd52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -462,8 +462,7 @@ and `cargo xtask publish [--run ] [--dry-run]` copies one run's bun geometry), pages generated whole by `cargo xtask coverage` (coverage.md), `cargo xtask steps` (branch-tests.md) and `generate_subroutine_validation` (subroutines/). Every published number is derived from a fixture directory (`tests/fixtures/xfoil//` or `target/fixtures//` for `--big` cases) or from `tests/fixtures/subroutines/`. -Only Markdown, ironlab figures (`.fig`, embedded live in the site) and the branch-coverage study's two SVGs are -committed under `docs/`. No XFOIL dump, `.dat`, `.pol`, `DUMP`/`CPWR` output or other reference data is ever +Only Markdown and ironlab figures (`.fig`, embedded live in the site) are committed under `docs/`. No XFOIL dump, `.dat`, `.pol`, `DUMP`/`CPWR` output or other reference data is ever committed there — `.gitignore` enforces it — and a table that cannot be regenerated from tracked inputs plus `cargo xtask fixtures` is not evidence. The studies: `cargo run -p aerofoil-series` (the validation selection, one figure per generator family, the naca456 comparison), `cargo run -p series-cases`, `cargo run -p known-issues`, @@ -474,11 +473,10 @@ comparison, `studies/study-support/records.rs` — study evidence beside the tes user guide's figures (`docs/user-guide/figures/`) are the output of the commands the guide prints, regenerated by `scripts/guide-figures.sh`. Figures are ironlab figures (`docs/development/conventions/figures.md`), built in Rust by each study from the numbers it -computes, through `src/output/figures.rs` (colours, ordered-family colours, parameters and labels the ironlab viewer -filters by, linked axes, validated saving); run folders also hold a PDF of each. The exception is the -branch-coverage study's branch map and case matrix, still drawn by matplotlib (`studies/branch-coverage/plot.py` -through `studies/figures/render.sh`, which needs **`uv` installed**) until ironlab has an occupancy-matrix figure -type. The analysis report (BL-distribution difference tables and +computes, through `src/output/figures.rs` (colours, ordered-family colours, parameters and tags the ironlab viewer +filters by, linked axes, saving that refuses a figure which does not validate or lay out); run folders also hold a +PDF of each. The branch-coverage study's branch map and case matrix are ironlab occupancy and cell figures too. The +analysis report (BL-distribution difference tables and plots for the ±15° N=160 polar) is generated from the `--big` fixture case and is the remaining validation stage. ## Conventions diff --git a/Cargo.lock b/Cargo.lock index bb2199dd..e8d396ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,6 +568,7 @@ dependencies = [ name = "branch-coverage" version = "0.1.0" dependencies = [ + "ironlab", "serde", "serde_json", "toml", @@ -1779,9 +1780,9 @@ dependencies = [ [[package]] name = "ironlab" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e871db7a272d22e8f165c860f88446f15503d893d9b623ffe23371bc1c47594" +checksum = "d65b727b3a22c073d0313a7734fef667cd34702c209a228a49512cb58ebe0148" dependencies = [ "eframe", "ironlab-canvas", @@ -1796,9 +1797,9 @@ dependencies = [ [[package]] name = "ironlab-canvas" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63c461e95cdda6002a63189d7ee0105817e75aaef9267fb98bc2df151c28f34b" +checksum = "d3fd7c9db424ba9867b5f9e00188ddc85913c8f124c5c179fcfc44e561eeb6cf" dependencies = [ "bytemuck", "ecolor", @@ -1818,9 +1819,9 @@ dependencies = [ [[package]] name = "ironlab-ir" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7408e7779898f072a4f228f5e1168fc3907642ce1859c223c9913cf52345f1f9" +checksum = "98b1753e5234cc2dd7c0b5db5a00f833dd788f543dfaac4e9a5b78b0046dd955" dependencies = [ "prost", "schemars", @@ -1831,9 +1832,9 @@ dependencies = [ [[package]] name = "ironlab-pdf" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c330eaf6294ab321224398d6158ce790deda6229a01f7749039bcbd8363ef892" +checksum = "4d2ee6dfd4beac3197d166b256e8542b8da5c7201a4b64f9df223773b2b6fdcb" dependencies = [ "ironlab-ir", "ironlab-scene", @@ -1844,9 +1845,9 @@ dependencies = [ [[package]] name = "ironlab-scene" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d40368c81998b3d0e4b70d622997604c7ab4d2d1ed8e6de128b782358b399dc" +checksum = "6270aaa05242fce24dd390749b4cd51a68ff97ffdecec9f869baa7073961e84d" dependencies = [ "ironlab-ir", "ironlab-text", @@ -1856,9 +1857,9 @@ dependencies = [ [[package]] name = "ironlab-text" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb80068a4d4d5103b97f73ccb55e0da46fa400542b23af367976c108c118f84" +checksum = "51d73f342292dc6fe5fb0d3d1fa98bcd6c39ffc97c224acca8d2159ff2feb1d8" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1869,9 +1870,9 @@ dependencies = [ [[package]] name = "ironlab-viewer" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26921110578fa5352c220f697d81567acd299fdeaa966ecf21cfeb3f91071507" +checksum = "7c806b4882d451db5ef8c5211fed6fb5a02da9a1490b4dc1c580910a47aee411" dependencies = [ "eframe", "egui", diff --git a/Cargo.toml b/Cargo.toml index 5c0db7ef..acdd0940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ env_logger = "0.11" # Optional figures (behind feature flag): ironlab figure files, pinned exactly because the # figure model is still moving between minor versions -ironlab = { version = "=0.12.0", optional = true } +ironlab = { version = "=0.16.0", optional = true } [dev-dependencies] approx = "0.5" diff --git a/docs/development/conventions/figures.md b/docs/development/conventions/figures.md index daaf18d3..76057f70 100644 --- a/docs/development/conventions/figures.md +++ b/docs/development/conventions/figures.md @@ -7,11 +7,10 @@ icon: lucide/image Every yFoil figure is an [ironlab](https://ironlab.org) figure: a file holding the figure's own data structure (`.fig`, Protocol Buffers; `.fig.json`, the same model as JSON), not a picture of it. It is opened in the ironlab viewer, embedded live in this site, and exported to PDF for publication. The shared conventions -are implemented once, in `src/output/figures.rs` (feature `plotting`), and every figure is built through it. - -The exception is the branch-coverage study's two occupancy figures (`docs/validation/branch-coverage/`): the branch map -and the case × subroutine matrix are not charts, and stay SVG drawn by matplotlib (`studies/branch-coverage/plot.py` -through `studies/figures/render.sh`) until ironlab has a figure type for them. +are implemented once, in `src/output/figures.rs` (feature `plotting`), and every figure is built through it. That +includes the branch-coverage study's occupancy figures (`docs/validation/branch-coverage/`): the branch map is an +ironlab occupancy matrix (`ax.occupancy` under `ax.ycategory_groups`) and the case × subroutine matrix is `ax.cells` +on categorical axes. ## Who computes what @@ -30,9 +29,9 @@ axis extents. There is no presentation layer that transforms data. colourmap for line colours. The family's default or base case is black and drawn on top. - **Unordered families of up to four** use `figures::CATEGORY`. -## Parameters and labels +## Parameters and tags -The viewer sorts, filters and groups figures by their **parameters** (name → value) and **labels** (plain words). +The viewer sorts, filters and groups figures by their **parameters** (name → value) and **tags** (plain words). Every figure carries both, so that all the figures every study produces can be loaded into one viewer and sliced. **Parameters** say *which* figure this is and *what went in and came out*: @@ -47,13 +46,26 @@ Every figure carries both, so that all the figures every study produces can be l A name that exists as a key in yFoil's own JSON output is spelled exactly as that key. A new value at a place is named `__`. -**Labels** are for tags a reader filters on, not for values: +**Tags** (`fig.tag`) are words a reader filters on, not values: - the documentation section a figure appears in: `validation`; - `xfoil-comparison` for a figure that plots the reference against yFoil; - regime tags taken from the inputs: `inviscid`, `compressible` (M > 0), `forced-transition`, `fixed-cl`, `low-re` (Re < 1e5). +## Axes + +- **Geometry** (an aerofoil section, offsets drawn normal to its surface) is drawn at `Aspect::Equal`, which the + layout, the viewer and export all keep. Limits are never widened by hand to fake it. +- **Pressure coefficient** is plotted as \(C_p\) on a reversed axis (`yreverse(true)`), suction up, never as + \(-C_p\). +- **Rows of names** (cases, subroutines) are categorical axes (`ycategories`, first at the top), set in monospace + with `\texttt{…}` (escape `_`). +- **Reference lines** (a tolerance, interval ends) are `hline` / `vline`, which span the axes at any zoom. +- **Non-finite values** are marked by `Marker::VLine` ticks at the top edge, at data x and axes-fraction y with + clipping off, so they stay at the edge while the axes autoscale, pan and zoom. +- **Stacks sharing x** show x tick labels on the bottom row only (`xtick_labels_hidden()` above). + ## Linked axes Panels that share an independent variable (α, x/c) have that axis linked across the whole figure. Panels that plot @@ -62,14 +74,22 @@ dependent axis linked too. Two axes with different quantities are never linked, ## Legends -Where a figure has one key for many panels, the legend goes in a **panel legend** (`fig.panel_legend(row, col)` + -`ax.legend_in(key)`) in a tile of its own. This replaces the hand-placed legend strips of the matplotlib figures. +Where a figure has one key for many panels, the legend goes in a **panel legend** (`fig.panel(row, col).legend()` + +`ax.legend_in(key)`) in a tile of its own, whose row is sized to it (`fit_row`), never by a calibrated weight. A legend that belongs to one panel stays in that panel. +- **A reference and its variants**: the default section or base case is drawn last, so it is on top, and listed + first, on a row of its own (`full_row`), the members after it in order. +- **Meanings shared by many artists** (a marker shape for a kind of point, a colour for a side) are explained once by + `custom_item` entries, in a key of their own if the figure also names curves, never by dummy artists or one entry + per combination. +- Columns are `LegendEntryColumns::Auto` or a fixed count, never chosen by trial export. + ## Saving -- `figures::save(fig, path)` validates the figure and refuses to write one with validation errors. The encoding - follows the extension. +- `figures::save(fig, path)` validates the figure and refuses to write one with validation errors, or whose layout + (`Figure::layout_warnings`) overflows a legend, clashes two legend entries or puts text beyond the page. The + encoding follows the extension. - `figures::save_with_pdf(fig, stem)` also writes the PDF, for the run folders' LaTeX reports. - Study run folders hold `.fig` and `.pdf`. The documentation holds `.fig` only. - The user guide's figures (`docs/user-guide/figures/`) are the output of the very commands the guide prints, run by diff --git a/docs/development/testing/README.md b/docs/development/testing/README.md index f183118d..aa67b915 100644 --- a/docs/development/testing/README.md +++ b/docs/development/testing/README.md @@ -18,10 +18,8 @@ measure the reference and compare whole runs, and add nothing the tests rely on. Figures are [ironlab](https://ironlab.org) figures, built in Rust by each study from the numbers it computes (`docs/development/conventions/figures.md`). They are embedded live in these pages: pan, zoom and toggle lines from the -legend. Each carries parameters (case, conditions, outputs) and labels, so every study's figures can be loaded -together into the ironlab viewer and sorted, filtered and grouped. A study's run folder also holds a PDF of each. The branch-coverage study's branch map and case matrix are the exception: they are SVGs drawn by -matplotlib (`studies/branch-coverage/plot.py` through `studies/figures/render.sh`, which needs -[`uv`](https://docs.astral.sh/uv/) installed). +legend. Each carries parameters (case, conditions, outputs) and tags, so every study's figures can be loaded +together into the ironlab viewer and sorted, filtered and grouped. A study's run folder also holds a PDF of each. | Page | What it shows | Regenerate with | |---|---|---| diff --git a/docs/user-guide/commands/plotting.md b/docs/user-guide/commands/plotting.md index 30ba4354..48abda20 100644 --- a/docs/user-guide/commands/plotting.md +++ b/docs/user-guide/commands/plotting.md @@ -19,7 +19,7 @@ Open a figure in the ironlab viewer, a desktop application that pans, zooms, rea the curves and hides series from the key: ``` sh -cargo install ironlab-viewer --version 0.12.0 +cargo install ironlab-viewer --version 0.16.0 ironlab-viewer naca0012.fig ironlab-viewer *.fig # several: the figure browser lists them ``` @@ -65,12 +65,11 @@ yfoil plot analysis a5.json # writes a5.fig For the NACA 4412 at α = 5°, Re = 10⁶: -NACA 4412 at alpha 5 degrees, Re 1e6: -Cp and Ue over x/c, upper and lower surfaces, above the aerofoil outline +NACA 4412 at alpha 5 degrees, Re 1e6: Cp and Ue over x/c, upper and lower surfaces, above the aerofoil outline -\(-C_p\) and \(U_e/U_\infty\) over \(x/c\) from a single-point analysis, upper surface +\(C_p\) and \(U_e/U_\infty\) over \(x/c\) from a single-point analysis, upper surface blue and lower red, above a strip with the aerofoil outline; the three share a linked -\(x\) axis. \(C_p\) is plotted negated, with suction up, because ironlab has no reversed -axis. A point whose numbers were withheld (an invalid point analysed without +\(x\) axis. The \(C_p\) axis is reversed, so suction is up. A point whose numbers were withheld (an invalid point analysed without `--allow-invalid`) has no distributions to draw, and is refused. ## The `plot foil` command { #foil } @@ -110,6 +109,7 @@ polar JSON written with `--distributions` (one design point per α). Each quantity gets its own hue and its own scale; each design point gets a tone and dash pattern, so several angles overlay legibly. The markers are open shapes, one per kind (○ stagnation, □ transition, ◇ forced transition, △ separation, ▽ reattachment), -yellow on the upper surface and green on the lower, and the key below the axes names each -kind and side drawn. The axes are scaled equally in \(x\) and \(y\) at the figure's own -size, so the section is undistorted; zooming in the viewer changes the two independently. +yellow on the upper surface and green on the lower. Below the axes are two keys: the curves, +and the markers, which explain each shape drawn once in grey and each side's colour once. +The axes are kept at equal aspect, in the viewer while panning and zooming too, so the +section and the offsets normal to it are undistorted. diff --git a/docs/user-guide/figures/a5.fig b/docs/user-guide/figures/a5.fig index df26fe5d..4d6c7925 100644 Binary files a/docs/user-guide/figures/a5.fig and b/docs/user-guide/figures/a5.fig differ diff --git a/docs/user-guide/figures/compare.fig b/docs/user-guide/figures/compare.fig index 6a563a0b..9d3da0ce 100644 Binary files a/docs/user-guide/figures/compare.fig and b/docs/user-guide/figures/compare.fig differ diff --git a/docs/user-guide/figures/foil.fig b/docs/user-guide/figures/foil.fig index c27af1d3..1ae81cfa 100644 Binary files a/docs/user-guide/figures/foil.fig and b/docs/user-guide/figures/foil.fig differ diff --git a/docs/user-guide/figures/intro.fig b/docs/user-guide/figures/intro.fig index d49b6fc4..6b3c7c2d 100644 Binary files a/docs/user-guide/figures/intro.fig and b/docs/user-guide/figures/intro.fig differ diff --git a/docs/user-guide/figures/naca0012_a5.fig b/docs/user-guide/figures/naca0012_a5.fig index 8288f88a..9826cd8c 100644 Binary files a/docs/user-guide/figures/naca0012_a5.fig and b/docs/user-guide/figures/naca0012_a5.fig differ diff --git a/docs/user-guide/figures/naca0012_polar.fig b/docs/user-guide/figures/naca0012_polar.fig index dfbd39ac..9d9569ad 100644 Binary files a/docs/user-guide/figures/naca0012_polar.fig and b/docs/user-guide/figures/naca0012_polar.fig differ diff --git a/docs/user-guide/figures/naca4412_foil.fig b/docs/user-guide/figures/naca4412_foil.fig index dfe60c3d..bfdac4f7 100644 Binary files a/docs/user-guide/figures/naca4412_foil.fig and b/docs/user-guide/figures/naca4412_foil.fig differ diff --git a/docs/user-guide/figures/naca4412_polar.fig b/docs/user-guide/figures/naca4412_polar.fig index f1c7e807..41a78c09 100644 Binary files a/docs/user-guide/figures/naca4412_polar.fig and b/docs/user-guide/figures/naca4412_polar.fig differ diff --git a/docs/user-guide/get-started/install.md b/docs/user-guide/get-started/install.md index 4b224715..4077628a 100644 --- a/docs/user-guide/get-started/install.md +++ b/docs/user-guide/get-started/install.md @@ -30,7 +30,7 @@ Plotting is behind a cargo feature; build with it enabled if you want ``` sh cargo install --path . --features plotting -cargo install ironlab-viewer --version 0.12.0 +cargo install ironlab-viewer --version 0.16.0 ``` ## Check it works diff --git a/docs/validation/aerofoil-series/figures/karman-trefftz.fig b/docs/validation/aerofoil-series/figures/karman-trefftz.fig index ae81df8f..bbec6c90 100644 Binary files a/docs/validation/aerofoil-series/figures/karman-trefftz.fig and b/docs/validation/aerofoil-series/figures/karman-trefftz.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-16.fig b/docs/validation/aerofoil-series/figures/naca-16.fig index ac34d265..4e01a264 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-16.fig and b/docs/validation/aerofoil-series/figures/naca-16.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-4-digit-modified.fig b/docs/validation/aerofoil-series/figures/naca-4-digit-modified.fig index 6a543d23..ea8a7e27 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-4-digit-modified.fig and b/docs/validation/aerofoil-series/figures/naca-4-digit-modified.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-4-digit.fig b/docs/validation/aerofoil-series/figures/naca-4-digit.fig index d95867d8..39df6cf0 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-4-digit.fig and b/docs/validation/aerofoil-series/figures/naca-4-digit.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-5-digit.fig b/docs/validation/aerofoil-series/figures/naca-5-digit.fig index 32734e39..05871185 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-5-digit.fig and b/docs/validation/aerofoil-series/figures/naca-5-digit.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-6.fig b/docs/validation/aerofoil-series/figures/naca-6.fig index 12f58986..b88d46c4 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-6.fig and b/docs/validation/aerofoil-series/figures/naca-6.fig differ diff --git a/docs/validation/aerofoil-series/figures/naca-6a.fig b/docs/validation/aerofoil-series/figures/naca-6a.fig index 440b878a..09195687 100644 Binary files a/docs/validation/aerofoil-series/figures/naca-6a.fig and b/docs/validation/aerofoil-series/figures/naca-6a.fig differ diff --git a/docs/validation/aerofoil-series/generator-families.md b/docs/validation/aerofoil-series/generator-families.md index 4dab3b02..d8fbd9f9 100644 --- a/docs/validation/aerofoil-series/generator-families.md +++ b/docs/validation/aerofoil-series/generator-families.md @@ -20,9 +20,9 @@ Parameters: m (maximum camber), p (its position), t (maximum thickness). The 1933 family of Report 460: a four-term polynomial thickness form on a two-parabola mean line, chosen to fit the best sections of the day (Clark Y, Göttingen 398). It remains the most used section family in general aviation and in codes' self-tests, and XFOIL's `NACA` command generates it. -NACA 4-digit: default NACA 0012 in black; maximum camber m (p = 0.4, t = 0.12); maximum-camber position p (m = 0.04, t = 0.12); maximum thickness t (m = 0) +NACA 4-digit: default NACA 0012 in black; maximum camber m (p = 0.4, t = 0.12); position of maximum camber p (m = 0.04, t = 0.12); maximum thickness t (m = 0) -*NACA 4-digit: default NACA 0012 in black; maximum camber m (p = 0.4, t = 0.12); maximum-camber position p (m = 0.04, t = 0.12); maximum thickness t (m = 0).* +*NACA 4-digit: default NACA 0012 in black; maximum camber m (p = 0.4, t = 0.12); position of maximum camber p (m = 0.04, t = 0.12); maximum thickness t (m = 0).* **In the validation set:** NACA 0012 is the symmetric baseline (Rule 6's XFOIL-independent invariants: C_L = C_M = 0 at α = 0, mirror symmetry at −α) and the CI reference case; NACA 4412 is the cambered 4-digit member and a real wing section (Lednicer lists the Champion 7EC Traveler). @@ -34,9 +34,9 @@ Parameters: m, p (the 2-digit mean line), t, I (leading-edge radius index), M (p Report 492 (1934) varied the leading-edge radius and the position of maximum thickness of the 4-digit form independently, at speeds up to and beyond the compressibility burble: index I = 6 is the 4-digit radius, 0 a sharp nose; M is in tenths of chord. The aft part is a cubic in (1 − x) with a fixed trailing-edge thickness. -NACA 4-digit modified: default NACA 0012-34 in black; leading-edge radius index I (M = 0.4, t = 0.12); maximum-thickness position M (I = 3, t = 0.12); maximum thickness t (I = 3, M = 0.4) +NACA 4-digit modified: default NACA 0012-34 in black; leading-edge radius index I (M = 0.4, t = 0.12); position of maximum thickness M (I = 3, t = 0.12); maximum thickness t (I = 3, M = 0.4) -*NACA 4-digit modified: default NACA 0012-34 in black; leading-edge radius index I (M = 0.4, t = 0.12); maximum-thickness position M (I = 3, t = 0.12); maximum thickness t (I = 3, M = 0.4).* +*NACA 4-digit modified: default NACA 0012-34 in black; leading-edge radius index I (M = 0.4, t = 0.12); position of maximum thickness M (I = 3, t = 0.12); maximum thickness t (I = 3, M = 0.4).* **In the validation set:** NACA 0012-34 has a sharper nose (I = 3) and its maximum thickness further aft than the 0012, with the same t: the pair separates the effect of leading-edge radius on transition and stagnation-point behaviour from that of thickness. @@ -48,9 +48,9 @@ Parameters: cl (design lift coefficient), p (position of maximum camber), reflex Report 537 (1935) put the maximum camber unusually far forward — a cubic mean line joined to a straight (or, in the reflexed lines, a second cubic) aft part — to raise the maximum lift with little pitching moment. The 230 line (cl = 0.3, p = 0.15) is by far the most used. -NACA 5-digit: default NACA 23018 in black; design lift coefficient cl (p = 0.15, t = 0.18); maximum-camber position p (cl = 0.3, t = 0.18); reflex mean line, position p (cl = 0.3, t = 0.18); maximum thickness t (230 mean line) +NACA 5-digit: default NACA 23018 in black; design lift coefficient cl (p = 0.15, t = 0.18); position of maximum camber p (cl = 0.3, t = 0.18); reflex mean line, position p (cl = 0.3, t = 0.18); maximum thickness t (230 mean line) -*NACA 5-digit: default NACA 23018 in black; design lift coefficient cl (p = 0.15, t = 0.18); maximum-camber position p (cl = 0.3, t = 0.18); reflex mean line, position p (cl = 0.3, t = 0.18); maximum thickness t (230 mean line).* +*NACA 5-digit: default NACA 23018 in black; design lift coefficient cl (p = 0.15, t = 0.18); position of maximum camber p (cl = 0.3, t = 0.18); reflex mean line, position p (cl = 0.3, t = 0.18); maximum thickness t (230 mean line).* **In the validation set:** NACA 23018 is a real aircraft section (Lednicer lists the Beech 90 King Air root; the 23012 is its tip) and, with the camber concentrated at the nose, loads the leading edge heavily: transition and separation are exercised at the nose rather than mid-chord. @@ -76,9 +76,9 @@ Parameters: family (63…67: position of minimum pressure), cl (design lift coef The laminar-flow sections of Report 824 (1945): thickness forms derived by conformal mapping for a prescribed pressure distribution — the family digit is the position of minimum pressure in tenths of chord — on the a mean line, which carries uniform load to x = a and falls linearly to zero at the trailing edge. There is no closed form: the sections are defined by the tabulated ε and ψ mapping functions of TM-4741. -NACA 6-series: default NACA 63-415 in black; family: minimum-pressure position (cl = 0.4, t = 0.15); design lift coefficient cl (63, t = 0.15); extent of uniform loading a (63-415); maximum thickness t (63, cl = 0.4) +NACA 6-series: default NACA 63-415 in black; family: position of minimum pressure (cl = 0.4, t = 0.15); design lift coefficient cl (63, t = 0.15); extent of uniform loading a (63-415); maximum thickness t (63, cl = 0.4) -*NACA 6-series: default NACA 63-415 in black; family: minimum-pressure position (cl = 0.4, t = 0.15); design lift coefficient cl (63, t = 0.15); extent of uniform loading a (63-415); maximum thickness t (63, cl = 0.4).* +*NACA 6-series: default NACA 63-415 in black; family: position of minimum pressure (cl = 0.4, t = 0.15); design lift coefficient cl (63, t = 0.15); extent of uniform loading a (63-415); maximum thickness t (63, cl = 0.4).* **In the validation set:** NACA 63-415 was the standard wind-turbine section before dedicated families appeared; the Risø catalogue (Bertagnolio et al. 2001) compares measurements with both Navier–Stokes and XFOIL results for it. Its trailing edge closes, so it runs XFOIL's sharp-TE branch, and the 6-series is the family whose generator is *not* a formula. diff --git a/docs/validation/aerofoil-series/tables/run.md b/docs/validation/aerofoil-series/tables/run.md index a7443e0d..f5d3f032 100644 --- a/docs/validation/aerofoil-series/tables/run.md +++ b/docs/validation/aerofoil-series/tables/run.md @@ -1 +1 @@ -Results from the run of `cargo run --release -p aerofoil-series` at 2026-10-17T11-24-21Z (git untagged, dirty). +Results from the run of `cargo run --release -p aerofoil-series` at 2026-10-26T15-03-00Z (git untagged, dirty). diff --git a/docs/validation/branch-coverage/figures/branch-map.fig b/docs/validation/branch-coverage/figures/branch-map.fig new file mode 100644 index 00000000..a4b2f35e Binary files /dev/null and b/docs/validation/branch-coverage/figures/branch-map.fig differ diff --git a/docs/validation/branch-coverage/figures/branch-map.svg b/docs/validation/branch-coverage/figures/branch-map.svg deleted file mode 100644 index 70302b22..00000000 --- a/docs/validation/branch-coverage/figures/branch-map.svg +++ /dev/null @@ -1,9041 +0,0 @@ - - - - - - - - 2026-10-23T12:39:03.789334 - image/svg+xml - - - Matplotlib v3.11.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - xpanel.f - - - APCALC - - - NCALC - - - PSILIN - - - PSWLIN - - - GGCALC - - - QWCALC - - - QDCALC - - - XYWAKE - - - STFIND - - - IBLPAN - - - XICALC - - - UICALC - - - QVFUE - - - QISET - - - GAMQV - - - STMOVE - - - UESET - - - xbl.f - - - SETBL - - - IBLSYS - - - MRCHUE - - - MRCHDU - - - XIFSET - - - UPDATE - - - xblsys.f - - - AXSET - - - TRCHEK2 - - - BLSYS - - - TESYS - - - BLVAR - - - BLMID - - - TRDIF - - - BLDIF - - - DAMPL - - - DAMPL2 - - - DIL - - - HSL - - - CFL - - - HST - - - xoper.f - - - SPECAL - - - SPECCL - - - VISCAL - - - xsolve.f - - - GAUSS - - - LUDCMP - - - BAKSUB - - - BLSOLV - - - xfoil.f - - - MRCL - - - COMSET - - - CPCALC - - - CLCALC - - - CDCALC - - - TECALC - - - xutils.f - - - SETEXP - - - - - - - - - - - - - - - - - - - - taken by the finite set - - - taken by the non-finite set - - - taken only in pathological cases - - - not reachable - - - not taken - - - - - - - - - - - - diff --git a/docs/validation/branch-coverage/figures/case-matrix.fig b/docs/validation/branch-coverage/figures/case-matrix.fig new file mode 100644 index 00000000..ada9cf03 Binary files /dev/null and b/docs/validation/branch-coverage/figures/case-matrix.fig differ diff --git a/docs/validation/branch-coverage/figures/case-matrix.svg b/docs/validation/branch-coverage/figures/case-matrix.svg deleted file mode 100644 index 333a47b6..00000000 --- a/docs/validation/branch-coverage/figures/case-matrix.svg +++ /dev/null @@ -1,5464 +0,0 @@ - - - - - - - - 2026-10-23T12:39:04.425386 - image/svg+xml - - - Matplotlib v3.11.1, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - kt_n60_inviscid_a4_cl05_cl8 - - - - - - naca0012_n60_a2_re1e6_type3 - - - - - - naca0012_n60_a8_re1e4_damp - - - - - - naca23012_n60_a4_re1e6_xtr022_0001 - - - - - - naca4412_n160_polar_down16_re1e6_iter100 - - - - - - naca4412_n60_a18_re3e6_iter40 - - - - - - naca4412_n60_a20_re1e6_iter50 - - - - - - naca4412_n60_cl1_clm05_re1e6 - - - - - - naca64a010_n60_inviscid_type2_m03_am23_a0_a19 - - - - - - naca64a010_n60_type2_a0_re1e6 - - - - - - - - APCALC - - - - - - NCALC - - - - - - PSILIN - - - - - - PSWLIN - - - - - - GGCALC - - - - - - QWCALC - - - - - - QDCALC - - - - - - XYWAKE - - - - - - STFIND - - - - - - IBLPAN - - - - - - XICALC - - - - - - UICALC - - - - - - QVFUE - - - - - - QISET - - - - - - GAMQV - - - - - - STMOVE - - - - - - UESET - - - - - - SETBL - - - - - - IBLSYS - - - - - - MRCHUE - - - - - - MRCHDU - - - - - - XIFSET - - - - - - UPDATE - - - - - - AXSET - - - - - - TRCHEK2 - - - - - - BLSYS - - - - - - TESYS - - - - - - BLVAR - - - - - - BLMID - - - - - - TRDIF - - - - - - BLDIF - - - - - - DAMPL - - - - - - DAMPL2 - - - - - - DIL - - - - - - HSL - - - - - - CFL - - - - - - HST - - - - - - SPECAL - - - - - - SPECCL - - - - - - VISCAL - - - - - - GAUSS - - - - - - LUDCMP - - - - - - BAKSUB - - - - - - BLSOLV - - - - - - MRCL - - - - - - COMSET - - - - - - CPCALC - - - - - - CLCALC - - - - - - CDCALC - - - - - - TECALC - - - - - - SETEXP - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - branches - - - reachable - - - taken, all cases - - - 6 - - - 5 - - - 5 - - - 12 - - - 7 - - - 7 - - - 68 - - - 36 - - - 36 - - - 20 - - - 17 - - - 17 - - - 20 - - - 20 - - - 20 - - - 2 - - - 2 - - - 2 - - - 42 - - - 42 - - - 42 - - - 6 - - - 5 - - - 5 - - - 12 - - - 10 - - - 10 - - - 10 - - - 9 - - - 9 - - - 14 - - - 14 - - - 14 - - - 4 - - - 4 - - - 4 - - - 4 - - - 4 - - - 4 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 26 - - - 26 - - - 26 - - - 8 - - - 8 - - - 8 - - - 82 - - - 81 - - - 81 - - - 6 - - - 5 - - - 5 - - - 104 - - - 101 - - - 101 - - - 110 - - - 109 - - - 109 - - - 8 - - - 8 - - - 8 - - - 86 - - - 86 - - - 86 - - - 6 - - - 6 - - - 6 - - - 44 - - - 44 - - - 44 - - - 26 - - - 26 - - - 26 - - - 4 - - - 4 - - - 4 - - - 42 - - - 42 - - - 42 - - - 8 - - - 8 - - - 8 - - - 12 - - - 12 - - - 12 - - - 18 - - - 18 - - - 18 - - - 4 - - - 4 - - - 4 - - - 10 - - - 10 - - - 10 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 2 - - - 6 - - - 6 - - - 6 - - - 22 - - - 20 - - - 20 - - - 16 - - - 14 - - - 14 - - - 46 - - - 41 - - - 41 - - - 24 - - - 24 - - - 24 - - - 26 - - - 25 - - - 25 - - - 14 - - - 14 - - - 14 - - - 52 - - - 52 - - - 52 - - - 22 - - - 16 - - - 16 - - - 2 - - - 2 - - - 2 - - - 6 - - - 6 - - - 6 - - - 4 - - - 4 - - - 4 - - - 8 - - - 8 - - - 8 - - - 2 - - - 2 - - - 2 - - - 12 - - - 8 - - - 8 - - - - - - - - - - - - - - - - - - 0 - - - - - - - - - - 0.25 - - - - - - - - - - 0.5 - - - - - - - - - - 0.75 - - - - - - - - - - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - subroutine not entered by the case - - - takes a branch no other finite candidate takes - - - - fraction of the subroutine's branches the case took - - - - - - - - - - - - - - diff --git a/docs/validation/branch-coverage/index.md b/docs/validation/branch-coverage/index.md index 8112bbc9..56b524d1 100644 --- a/docs/validation/branch-coverage/index.md +++ b/docs/validation/branch-coverage/index.md @@ -17,7 +17,7 @@ CLAUDE.md Rule 6: "numerically exact" is claimable only over branches that have --8<-- "validation/branch-coverage/tables/status.md" -![Branch map](figures/branch-map.svg) +Every branch of the analysis path, one row per subroutine grouped by source file, coloured by status *Every branch of the analysis path, one row per subroutine in XFOIL's file order: taken by the finite set (green), taken by the non-finite set (blue), taken only in pathological cases (orange), not reachable — annotated unreachable or a never-taken DO-line edge (grey), not taken (red).* diff --git a/docs/validation/branch-coverage/tables/subroutine-counts.md b/docs/validation/branch-coverage/tables/subroutine-counts.md new file mode 100644 index 00000000..b321c763 --- /dev/null +++ b/docs/validation/branch-coverage/tables/subroutine-counts.md @@ -0,0 +1,53 @@ +| file | subroutine | branches | reachable | taken, all cases | +|---|---|---:|---:|---:| +| `xpanel.f` | `APCALC` | 6 | 5 | 5 | +| `xpanel.f` | `NCALC` | 12 | 7 | 7 | +| `xpanel.f` | `PSILIN` | 68 | 36 | 36 | +| `xpanel.f` | `PSWLIN` | 20 | 17 | 17 | +| `xpanel.f` | `GGCALC` | 20 | 20 | 20 | +| `xpanel.f` | `QWCALC` | 2 | 2 | 2 | +| `xpanel.f` | `QDCALC` | 42 | 42 | 42 | +| `xpanel.f` | `XYWAKE` | 6 | 5 | 5 | +| `xpanel.f` | `STFIND` | 12 | 10 | 10 | +| `xpanel.f` | `IBLPAN` | 10 | 9 | 9 | +| `xpanel.f` | `XICALC` | 14 | 14 | 14 | +| `xpanel.f` | `UICALC` | 4 | 4 | 4 | +| `xpanel.f` | `QVFUE` | 4 | 4 | 4 | +| `xpanel.f` | `QISET` | 2 | 2 | 2 | +| `xpanel.f` | `GAMQV` | 2 | 2 | 2 | +| `xpanel.f` | `STMOVE` | 26 | 26 | 26 | +| `xpanel.f` | `UESET` | 8 | 8 | 8 | +| `xbl.f` | `SETBL` | 82 | 81 | 81 | +| `xbl.f` | `IBLSYS` | 6 | 5 | 5 | +| `xbl.f` | `MRCHUE` | 104 | 101 | 101 | +| `xbl.f` | `MRCHDU` | 110 | 109 | 109 | +| `xbl.f` | `XIFSET` | 8 | 8 | 8 | +| `xbl.f` | `UPDATE` | 86 | 86 | 86 | +| `xblsys.f` | `AXSET` | 6 | 6 | 6 | +| `xblsys.f` | `TRCHEK2` | 44 | 44 | 44 | +| `xblsys.f` | `BLSYS` | 26 | 26 | 26 | +| `xblsys.f` | `TESYS` | 4 | 4 | 4 | +| `xblsys.f` | `BLVAR` | 42 | 42 | 42 | +| `xblsys.f` | `BLMID` | 8 | 8 | 8 | +| `xblsys.f` | `TRDIF` | 12 | 12 | 12 | +| `xblsys.f` | `BLDIF` | 18 | 18 | 18 | +| `xblsys.f` | `DAMPL` | 4 | 4 | 4 | +| `xblsys.f` | `DAMPL2` | 10 | 10 | 10 | +| `xblsys.f` | `DIL` | 2 | 2 | 2 | +| `xblsys.f` | `HSL` | 2 | 2 | 2 | +| `xblsys.f` | `CFL` | 2 | 2 | 2 | +| `xblsys.f` | `HST` | 6 | 6 | 6 | +| `xoper.f` | `SPECAL` | 22 | 20 | 20 | +| `xoper.f` | `SPECCL` | 16 | 14 | 14 | +| `xoper.f` | `VISCAL` | 46 | 41 | 41 | +| `xsolve.f` | `GAUSS` | 24 | 24 | 24 | +| `xsolve.f` | `LUDCMP` | 26 | 25 | 25 | +| `xsolve.f` | `BAKSUB` | 14 | 14 | 14 | +| `xsolve.f` | `BLSOLV` | 52 | 52 | 52 | +| `xfoil.f` | `MRCL` | 22 | 16 | 16 | +| `xfoil.f` | `COMSET` | 2 | 2 | 2 | +| `xfoil.f` | `CPCALC` | 6 | 6 | 6 | +| `xfoil.f` | `CLCALC` | 4 | 4 | 4 | +| `xfoil.f` | `CDCALC` | 8 | 8 | 8 | +| `xfoil.f` | `TECALC` | 2 | 2 | 2 | +| `xutils.f` | `SETEXP` | 12 | 8 | 8 | diff --git a/docs/validation/branch-coverage/the-minimal-finite-set.md b/docs/validation/branch-coverage/the-minimal-finite-set.md index 46eb6d31..3a337420 100644 --- a/docs/validation/branch-coverage/the-minimal-finite-set.md +++ b/docs/validation/branch-coverage/the-minimal-finite-set.md @@ -10,9 +10,15 @@ Branches taken by exactly one finite candidate make that candidate essential; th Every case: yFoil generates the panels (`--method cosine`), XFOIL `LOAD`s them (bitwise handoff asserted), one OPER script, one instrumented run plus its five 1-ULP twins. -![Case × subroutine](figures/case-matrix.svg) +Fraction of each subroutine's branches taken by each case of the minimal finite set -*Fraction of each subroutine's branches taken by each case of the set; a dot marks a subroutine in which the case takes a branch no other finite candidate takes. The three columns on the right count, per subroutine, every branch, the reachable ones (not annotated unreachable and not a never-taken DO-line edge) and those taken on any measured run, the non-finite set included.* +*Fraction of each subroutine's branches taken by each case of the set; a light grey cell is a subroutine the case never entered, and a dot marks a subroutine in which the case takes a branch no other finite candidate takes.* + +??? note "Branches of each subroutine" + + Per subroutine, every branch, the reachable ones (not annotated unreachable and not a never-taken DO-line edge) and those taken on any measured run, the non-finite set included. + + --8<-- "validation/branch-coverage/tables/subroutine-counts.md" ## Whole-run comparison diff --git a/docs/validation/known-issues/figures/polars-naca0012_n240_polar30_re1e6_iter200.fig b/docs/validation/known-issues/figures/polars-naca0012_n240_polar30_re1e6_iter200.fig index d9281ce0..5d716344 100644 Binary files a/docs/validation/known-issues/figures/polars-naca0012_n240_polar30_re1e6_iter200.fig and b/docs/validation/known-issues/figures/polars-naca0012_n240_polar30_re1e6_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca16-212_n160_polar30_re1e6_m07_iter100.fig b/docs/validation/known-issues/figures/polars-naca16-212_n160_polar30_re1e6_m07_iter100.fig index aec345e2..ddf76c3d 100644 Binary files a/docs/validation/known-issues/figures/polars-naca16-212_n160_polar30_re1e6_m07_iter100.fig and b/docs/validation/known-issues/figures/polars-naca16-212_n160_polar30_re1e6_m07_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca16-212_n240_polar30_re1e6_m07_iter200.fig b/docs/validation/known-issues/figures/polars-naca16-212_n240_polar30_re1e6_m07_iter200.fig index 1dd964a5..7bb5d564 100644 Binary files a/docs/validation/known-issues/figures/polars-naca16-212_n240_polar30_re1e6_m07_iter200.fig and b/docs/validation/known-issues/figures/polars-naca16-212_n240_polar30_re1e6_m07_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca16-212_n60_polar30_re1e6_m07.fig b/docs/validation/known-issues/figures/polars-naca16-212_n60_polar30_re1e6_m07.fig index ac61d0cc..053b6318 100644 Binary files a/docs/validation/known-issues/figures/polars-naca16-212_n60_polar30_re1e6_m07.fig and b/docs/validation/known-issues/figures/polars-naca16-212_n60_polar30_re1e6_m07.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca23012_n60_polar30_re1e6_xtr022_0001.fig b/docs/validation/known-issues/figures/polars-naca23012_n60_polar30_re1e6_xtr022_0001.fig index 048a133f..c251465e 100644 Binary files a/docs/validation/known-issues/figures/polars-naca23012_n60_polar30_re1e6_xtr022_0001.fig and b/docs/validation/known-issues/figures/polars-naca23012_n60_polar30_re1e6_xtr022_0001.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n160_inviscid_polar30_m07.fig b/docs/validation/known-issues/figures/polars-naca4412_n160_inviscid_polar30_m07.fig index 0d1cc339..559e44c9 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n160_inviscid_polar30_m07.fig and b/docs/validation/known-issues/figures/polars-naca4412_n160_inviscid_polar30_m07.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n240_inviscid_polar30_m07.fig b/docs/validation/known-issues/figures/polars-naca4412_n240_inviscid_polar30_m07.fig index f4011562..451fac9f 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n240_inviscid_polar30_m07.fig and b/docs/validation/known-issues/figures/polars-naca4412_n240_inviscid_polar30_m07.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_iter200.fig b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_iter200.fig index dec3e56f..9931840f 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_iter200.fig and b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_m06_iter200.fig b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_m06_iter200.fig index f30dd9a4..5a64e4dd 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_m06_iter200.fig and b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re1e6_m06_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re3e6_iter200.fig b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re3e6_iter200.fig index 0f0cf568..e9525577 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re3e6_iter200.fig and b/docs/validation/known-issues/figures/polars-naca4412_n240_polar30_re3e6_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca4412_n60_inviscid_polar30_m07.fig b/docs/validation/known-issues/figures/polars-naca4412_n60_inviscid_polar30_m07.fig index f85e7fbe..7955c842 100644 Binary files a/docs/validation/known-issues/figures/polars-naca4412_n60_inviscid_polar30_m07.fig and b/docs/validation/known-issues/figures/polars-naca4412_n60_inviscid_polar30_m07.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_iter100.fig b/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_iter100.fig index 39e515ee..3f718c4d 100644 Binary files a/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_iter100.fig and b/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_m05_iter100.fig b/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_m05_iter100.fig index 53681410..c947d5c5 100644 Binary files a/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_m05_iter100.fig and b/docs/validation/known-issues/figures/polars-naca63-415_n160_polar30_re1e6_m05_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n160_inviscid_polar30_type2_m03.fig b/docs/validation/known-issues/figures/polars-naca64a010_n160_inviscid_polar30_type2_m03.fig index 858864e9..91624ef3 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n160_inviscid_polar30_type2_m03.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n160_inviscid_polar30_type2_m03.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_iter100.fig b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_iter100.fig index 13984121..bb073aed 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_iter100.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_m03_type2_iter100.fig b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_m03_type2_iter100.fig index 724605f9..9fd77517 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_m03_type2_iter100.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_m03_type2_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_type2_iter100.fig b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_type2_iter100.fig index 30051e32..03a9e911 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_type2_iter100.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n160_polar30_re1e6_type2_iter100.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n240_inviscid_polar30_type2_m03.fig b/docs/validation/known-issues/figures/polars-naca64a010_n240_inviscid_polar30_type2_m03.fig index 976a14f5..82445191 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n240_inviscid_polar30_type2_m03.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n240_inviscid_polar30_type2_m03.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n240_polar30_re1e6_type2_iter200.fig b/docs/validation/known-issues/figures/polars-naca64a010_n240_polar30_re1e6_type2_iter200.fig index c2e071e8..30c4492f 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n240_polar30_re1e6_type2_iter200.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n240_polar30_re1e6_type2_iter200.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n60_inviscid_polar30_type2_m03.fig b/docs/validation/known-issues/figures/polars-naca64a010_n60_inviscid_polar30_type2_m03.fig index bf40ea1c..dfb6a0f6 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n60_inviscid_polar30_type2_m03.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n60_inviscid_polar30_type2_m03.fig differ diff --git a/docs/validation/known-issues/figures/polars-naca64a010_n60_polar30_re1e6_type2.fig b/docs/validation/known-issues/figures/polars-naca64a010_n60_polar30_re1e6_type2.fig index 7d677494..b410fa9e 100644 Binary files a/docs/validation/known-issues/figures/polars-naca64a010_n60_polar30_re1e6_type2.fig and b/docs/validation/known-issues/figures/polars-naca64a010_n60_polar30_re1e6_type2.fig differ diff --git a/docs/validation/series-cases/figures/polars-kt_n160_inviscid_polar30.fig b/docs/validation/series-cases/figures/polars-kt_n160_inviscid_polar30.fig index 1943c331..962da86e 100644 Binary files a/docs/validation/series-cases/figures/polars-kt_n160_inviscid_polar30.fig and b/docs/validation/series-cases/figures/polars-kt_n160_inviscid_polar30.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e4_damp_iter100.fig b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e4_damp_iter100.fig index b93d2c75..9f566f42 100644 Binary files a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e4_damp_iter100.fig and b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e4_damp_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_iter100.fig b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_iter100.fig index 09c396ba..61600a07 100644 Binary files a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_iter100.fig and b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_ncrit4_iter100.fig b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_ncrit4_iter100.fig index d847dc04..82eeb52b 100644 Binary files a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_ncrit4_iter100.fig and b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e6_ncrit4_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e8_iter100.fig b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e8_iter100.fig index 0a727eda..5b88dd02 100644 Binary files a/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e8_iter100.fig and b/docs/validation/series-cases/figures/polars-naca0012_n160_polar30_re1e8_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca16-212_n160_polar30_re1e6_m05_iter100.fig b/docs/validation/series-cases/figures/polars-naca16-212_n160_polar30_re1e6_m05_iter100.fig index e90a6749..459c7cd4 100644 Binary files a/docs/validation/series-cases/figures/polars-naca16-212_n160_polar30_re1e6_m05_iter100.fig and b/docs/validation/series-cases/figures/polars-naca16-212_n160_polar30_re1e6_m05_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca23012_n160_polar30_re1e6_xtr022_022_iter100.fig b/docs/validation/series-cases/figures/polars-naca23012_n160_polar30_re1e6_xtr022_022_iter100.fig index 9e5cc73a..71b59d7f 100644 Binary files a/docs/validation/series-cases/figures/polars-naca23012_n160_polar30_re1e6_xtr022_022_iter100.fig and b/docs/validation/series-cases/figures/polars-naca23012_n160_polar30_re1e6_xtr022_022_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_clsweep_re1e6_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_clsweep_re1e6_iter100.fig index 7fbbd448..172a88ab 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_clsweep_re1e6_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_clsweep_re1e6_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_iter100.fig index abe3bf7c..d7d0bef3 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m05_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m05_iter100.fig index 55cf653a..061322a9 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m05_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m05_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m06_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m06_iter100.fig index 36eae08f..ecad491e 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m06_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_m06_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_ncrit11_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_ncrit11_iter100.fig index b1074f41..1a97b9d4 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_ncrit11_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e6_ncrit11_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e7_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e7_iter100.fig index b1794b34..4aeafd30 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e7_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re1e7_iter100.fig differ diff --git a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re3e6_iter100.fig b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re3e6_iter100.fig index 4f205d7d..e5136343 100644 Binary files a/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re3e6_iter100.fig and b/docs/validation/series-cases/figures/polars-naca4412_n160_polar30_re3e6_iter100.fig differ diff --git a/docs/validation/series-cases/index.md b/docs/validation/series-cases/index.md index 95fdb3ec..5f97dce4 100644 --- a/docs/validation/series-cases/index.md +++ b/docs/validation/series-cases/index.md @@ -87,8 +87,8 @@ iteration of the point agrees with XFOIL's. It is hollow where the iterations pa where an earlier point in the same leg parted (that point started from a state XFOIL never computed, so its comparison is informational). -- An **orange square** marks a point where that code went through a non-finite value. An orange plus - at the top of a panel marks a value that is itself NaN and cannot be plotted. +- An **orange square** marks a point where that code went through a non-finite value. An orange tick + at the top edge of a panel marks a value that is itself NaN and cannot be plotted. - A **black diamond** marks where yFoil parts from XFOIL and XFOIL is ill-conditioned there (one of its own twins parts too). - A **grey diamond** marks where yFoil parts from XFOIL and XFOIL is well-conditioned (a mismatch). diff --git a/scripts/docs.sh b/scripts/docs.sh index 86531971..d12e8a4a 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -21,8 +21,8 @@ zensical_version="0.0.71" # The ironlab-web bundle that draws the embeds (docs/development/conventions/figures.md). # Pinned by version and checksum, fetched from the ironlab release into a versioned directory # (gitignored), so an upgrade changes every URL and no browser pairs a page with a stale engine. -ironlab_version="0.12.0" -ironlab_sha256="7f5d2a1225cbe4e699697347e2657ca4249784b0d4cc71514641f72d0dd0821b" +ironlab_version="0.16.0" +ironlab_sha256="ad1160b9052f555eb060390b3fbd04116448c10888f53eac69ecd8dbd2e67380" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" diff --git a/src/output/figures.rs b/src/output/figures.rs index b033592b..15997cad 100644 --- a/src/output/figures.rs +++ b/src/output/figures.rs @@ -1,5 +1,5 @@ //! Shared conventions for yFoil's ironlab figures: the colours, the ordered-family colours that -//! replace a colourmap, the parameters and labels the ironlab viewer sorts, filters and groups +//! replace a colourmap, the parameters and tags the ironlab viewer sorts, filters and groups //! by, and saving. The rules are in `docs/development/conventions/figures.md`. //! //! Figure size and fonts are ironlab's defaults (set up for AMS journals). A figure sets @@ -106,24 +106,39 @@ pub enum FigureError { Ironlab { path: String, source: ironlab::Error }, } +/// Refuse a figure with validation errors, or whose layout leaves something off the page or +/// out of its legend: entries that do not fit, two entries of one legend with the same name and +/// different samples, or text beyond the page edge. The layout warnings are scene warnings, not +/// validation errors, so a figure with one would otherwise be written silently. fn check(fig: &Figure, path: &Path) -> Result<(), FigureError> { let report = fig.validate(); - if report.errors.is_empty() { + let mut errors: Vec = report.errors.iter().map(|e| format!("{e:?}")).collect(); + if errors.is_empty() { + errors.extend( + fig.layout_warnings() + .into_iter() + .filter(|w| { + matches!( + w.kind, + ironlab::SceneWarningKind::LegendOverflow { .. } + | ironlab::SceneWarningKind::LegendNameClash { .. } + | ironlab::SceneWarningKind::TextOutsidePage { .. } + ) + }) + .map(|w| w.message), + ); + } + if errors.is_empty() { return Ok(()); } Err(FigureError::Invalid { path: path.display().to_string(), - errors: report - .errors - .iter() - .map(|e| format!("{e:?}")) - .collect::>() - .join("; "), + errors: errors.join("; "), }) } /// Validate and save a figure; the encoding follows the extension (`.fig` or `.fig.json`). -/// A figure with validation errors is never written. +/// A figure that does not validate or does not lay out ([`check`]) is never written. pub fn save(fig: &Figure, path: &Path) -> Result<(), FigureError> { check(fig, path)?; fig.save(path).map_err(|source| FigureError::Ironlab { @@ -133,38 +148,14 @@ pub fn save(fig: &Figure, path: &Path) -> Result<(), FigureError> { } /// Validate and save `.fig` and, for the run folders' LaTeX reports, `.pdf`. -/// -/// Drawing the PDF is also the only check ironlab offers on layout: a legend whose entries do -/// not fit, or two entries of one legend with the same name and different samples, are scene -/// warnings, not validation errors. Either would be written silently (an entry clipped from -/// its legend), so either refuses the figure and both files are removed. pub fn save_with_pdf(fig: &Figure, stem: &Path) -> Result<(), FigureError> { let fig_path = stem.with_extension("fig"); check(fig, &fig_path)?; let pdf = stem.with_extension("pdf"); - let report = fig.export_pdf(&pdf).map_err(|source| FigureError::Ironlab { + fig.export_pdf(&pdf).map_err(|source| FigureError::Ironlab { path: pdf.display().to_string(), source, })?; - let layout: Vec = report - .scene - .iter() - .filter(|w| { - matches!( - w.kind, - ironlab::SceneWarningKind::LegendOverflow { .. } | ironlab::SceneWarningKind::LegendNameClash { .. } - ) - }) - .map(|w| w.message.clone()) - .collect(); - if !layout.is_empty() { - let _ = std::fs::remove_file(&pdf); - let _ = std::fs::remove_file(&fig_path); - return Err(FigureError::Invalid { - path: fig_path.display().to_string(), - errors: layout.join("; "), - }); - } save(fig, &fig_path) } diff --git a/src/output/foil_plot.rs b/src/output/foil_plot.rs index 943968c0..d573d374 100644 --- a/src/output/foil_plot.rs +++ b/src/output/foil_plot.rs @@ -12,19 +12,21 @@ //! XFOIL has no scale factor on δ* and marks no transition or separation on the aerofoil plot; the //! scaling and the markers are yFoil's. //! -//! The figure is an ironlab figure (`docs/development/conventions/figures.md`). ironlab has no equal-aspect -//! axes, so the limits are widened in Rust to the aspect of the plot area the layout gives -//! ([`PLOT_AREA_MM`]), which keeps the aerofoil undistorted at the figure's own size. +//! The figure is an ironlab figure (`docs/development/conventions/figures.md`), drawn at equal +//! aspect so the aerofoil and the offsets normal to it are undistorted at any size or zoom. Its key +//! is two legends: the curves, and the markers, whose shape is the kind of location and whose +//! colour is the side, each explained once. use std::collections::BTreeMap; use std::path::Path; use ironlab::prelude::*; +use ironlab::{LineStyle, MarkerStyle}; use crate::geometry::PanelledFoil; use crate::output::plot::{ - condition_parameters, describe, point_parameters, regime_labels, rgb, save_figure, shared_labels, - shared_parameters, PlotError, STUDY, + condition_parameters, describe, point_parameters, regime_tags, rgb, save_figure, shared_parameters, shared_tags, + PlotError, STUDY, }; use crate::output::{ AnalysisOutput, BlQuantity, BoundaryLayerOutput, FoilNodes, PointRecord, PolarOutput, SeparationKind, SideStations, @@ -90,8 +92,15 @@ impl Default for MarkerSet { } } -/// The dash patterns cycled over design points: ironlab's four stroked patterns -pub const DASH_CYCLE: [Dash; 4] = [Dash::Solid, Dash::Dotted, Dash::Dashed, Dash::DashDot]; +/// The dash patterns cycled over design points: ironlab's six stroked patterns +pub const DASH_CYCLE: [Dash; 6] = [ + Dash::Solid, + Dash::Dotted, + Dash::Dashed, + Dash::DashDot, + Dash::LongDash, + Dash::DashDotDot, +]; /// Configuration of the foil figure #[derive(Debug, Clone)] @@ -396,6 +405,8 @@ const MARK_LOWER: (u8, u8, u8) = (0, 165, 0); const MARK_STAGNATION: (u8, u8, u8) = (40, 40, 40); /// Marker size (pt) const MARK_SIZE_PT: f64 = 5.0; +/// The marker key's shapes, which stand for the kind of location whatever its side +const MARK_KEY: (u8, u8, u8) = (128, 128, 128); /// The kinds of significant location, each with its own open shape #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -447,17 +458,6 @@ struct Mark { rgb: (u8, u8, u8), } -impl Mark { - /// The key entry of a (kind, side): its shape in its side's colour - fn key(&self) -> String { - match self.side { - 1 => format!("{} (upper surface)", self.kind.label()), - 2 => format!("{} (lower surface)", self.kind.label()), - _ => self.kind.label().to_string(), - } - } -} - /// Everything laid out in data coordinates before the figure exists #[derive(Debug)] struct Layout { @@ -702,30 +702,6 @@ fn layout(points: &[DesignPoint], config: &FoilPlotConfig) -> Result ((f64, f64), (f64, f64)) { - let (x0, x1, y0, y1) = bounds; - let (dx, dy) = (x1 - x0, y1 - y0); - let aspect = area.1 / area.0; - if dy / dx < aspect { - let half = 0.5 * dx * aspect; - let c = 0.5 * (y0 + y1); - ((x0, x1), (c - half, c + half)) - } else { - let half = 0.5 * dy / aspect; - let c = 0.5 * (x0 + x1); - ((c - half, c + half), (y0, y1)) - } -} - /// The parameters of the foil figure: provenance, and for one design point its inputs and /// outputs; for several, the conditions they share fn foil_parameters(points: &[DesignPoint]) -> (BTreeMap, Vec<&'static str>) { @@ -753,11 +729,44 @@ fn foil_parameters(points: &[DesignPoint]) -> (BTreeMap, Vec< parameters.extend(point_parameters(record, p.boundary_layer.as_ref())); } } - let labels: Vec> = points + let tags: Vec> = points .iter() - .map(|p| p.conditions.as_ref().map(regime_labels).unwrap_or_default()) + .map(|p| p.conditions.as_ref().map(regime_tags).unwrap_or_default()) .collect(); - (parameters, shared_labels(&labels)) + (parameters, shared_tags(&tags)) +} + +/// The marker key: one open grey shape per kind of location drawn, then one swatch per side +/// that has a marker +fn marker_key(markers: &[Mark]) -> Vec<(LegendSample, String)> { + let mut kinds: Vec = markers.iter().map(|m| m.kind).collect(); + kinds.sort(); + kinds.dedup(); + let mut key: Vec<(LegendSample, String)> = kinds + .into_iter() + .map(|kind| { + let sample = LegendSample::Line { + line: LineStyle { + color: ColorSpec::Rgba { color: rgb(MARK_KEY) }, + dash: Dash::None, + ..LineStyle::default() + }, + marker: MarkerStyle { + shape: kind.shape(), + size_pt: MARK_SIZE_PT, + face: ColorSpec::None, + edge: ColorSpec::Auto, + }, + }; + (sample, kind.label().to_string()) + }) + .collect(); + for (side, colour, name) in [(1, MARK_UPPER, "upper surface"), (2, MARK_LOWER, "lower surface")] { + if markers.iter().any(|m| m.side == side) { + key.push((LegendSample::swatch(rgb(colour)), name.to_string())); + } + } + key } /// Build the foil figure @@ -765,15 +774,13 @@ pub fn foil_figure(points: &[DesignPoint], config: &FoilPlotConfig) -> Result Result fig, + 1 => fig.tiles(2, 1).fit_row(1), + _ => fig.tiles(2, 2).fit_row(1), + }; + let (parameters, tags) = foil_parameters(points); + fig = describe(fig, parameters, &tags); + let curve_key = curves_named.then(|| fig.panel(1, 0).legend().id()); + if markers_keyed { + let mut panel = fig.panel(1, if curves_named { 1 } else { 0 }); + let mut legend = panel.legend(); + for (sample, name) in marker_key(&lay.markers) { + legend.custom_item(sample, name); + } + legend.columns(LegendEntryColumns::Auto); } - let (parameters, labels) = foil_parameters(points); - fig = describe(fig, parameters, &labels); - let key = named.then(|| fig.panel_legend(1, 0).id()); - let mut ax = fig.axes(0, 0); + let mut ax = if keys == 2 { + fig.axes_span(0, 0, 1, 2) + } else { + fig.axes(0, 0) + }; let draw = |ax: &mut AxesMut, c: &Curve, width: f64| { let (x, y): (Vec, Vec) = c.pts.iter().copied().unzip(); let mut line = ax.plot(x, y); line.color(rgb(c.rgb)).dash(c.dash).line_width(width); if let Some(name) = &c.name { - line.display_name(name.as_str()); + line.label(name.as_str()); } }; for c in &lay.surfaces { @@ -826,26 +851,23 @@ pub fn foil_figure(points: &[DesignPoint], config: &FoilPlotConfig) -> Result = ir.axes[0] + assert_eq!(ir.axes[0].aspect, Aspect::Equal); + assert_eq!(ir.panels.len(), 2, "a curve key and a marker key"); + let series: Vec<_> = ir.axes[0] .artists .iter() .filter_map(|a| match a { - Artist::Line(l) => Some(l), + Artist::Series(s) => Some(s), _ => None, }) .collect(); // surface + dots + (upper, lower) + stagnation, transition, forced transition - assert_eq!(lines.len(), 1 + 1 + 2 + 3); - let names: Vec<&str> = lines + assert_eq!(series.len(), 1 + 1 + 2 + 3); + let names: Vec<&str> = series .iter() - .filter_map(|l| l.display_name.as_ref().map(|t| t.content.as_str())) + .filter_map(|l| l.label.as_ref().map(|t| t.content.as_str())) .collect(); - assert_eq!(names.len(), 4); + assert_eq!(names.len(), 1, "only the curve is named; the markers are keyed once"); assert!(names[0].starts_with("$\\delta^*$, scaled ×")); + let shapes: Vec = series.iter().map(|l| l.marker.shape).collect(); + assert_eq!(shapes[1], Marker::Point); + assert_eq!(&shapes[4..], [Marker::Circle, Marker::Square, Marker::Diamond]); + let key: Vec = marker_key(&layout(&pts, &config).unwrap().markers) + .into_iter() + .map(|(_, name)| name) + .collect(); assert_eq!( - &names[1..], + key, [ "stagnation", - "transition (upper surface)", - "forced transition (lower surface)" + "transition", + "forced transition", + "upper surface", + "lower surface" ] ); - let shapes: Vec = lines.iter().map(|l| l.marker.shape).collect(); - assert_eq!(shapes[1], Marker::Point); - assert_eq!(&shapes[4..], [Marker::Circle, Marker::Square, Marker::Diamond]); assert_eq!(fig.parameters().get("figure"), Some(&Parameter::from("foil"))); assert_eq!(fmt_alpha(3.0000000000000004), "3"); assert_eq!(fmt_alpha(-2.5), "-2.5"); diff --git a/src/output/mod.rs b/src/output/mod.rs index 2cead47d..b93e379c 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -29,9 +29,8 @@ pub use results::{ #[cfg(feature = "plotting")] pub use foil_plot::{ - check_same_geometry, equal_aspect_limits, foil_figure, plot_foil, point_style, quantity_symbol, scale_factors, - surface_offset, wake_band, CurveStyle, DesignPoint, FoilPlotConfig, MarkerSet, OffsetScale, PanelStyle, Pt, - DASH_CYCLE, PLOT_AREA_MM, + check_same_geometry, foil_figure, plot_foil, point_style, quantity_symbol, scale_factors, surface_offset, + wake_band, CurveStyle, DesignPoint, FoilPlotConfig, MarkerSet, OffsetScale, PanelStyle, Pt, DASH_CYCLE, }; #[cfg(feature = "plotting")] pub use plot::{ diff --git a/src/output/plot.rs b/src/output/plot.rs index 91c9e77e..735e87b0 100644 --- a/src/output/plot.rs +++ b/src/output/plot.rs @@ -1,5 +1,5 @@ //! The `yfoil plot analysis` and `yfoil plot polar` figures, and what the three `yfoil plot` -//! figures share: their error type and the parameters and labels the ironlab viewer sorts, +//! figures share: their error type and the parameters and tags the ironlab viewer sorts, //! filters and groups by (`docs/development/conventions/figures.md`). The foil figure is in `foil_plot.rs`. //! //! Every figure is an ironlab figure, written as `.fig` (or `.fig.json`) through @@ -43,7 +43,7 @@ pub fn save_figure(fig: &Figure, path: &Path) -> Result<(), PlotError> { } // ============================================================================ -// Parameters and labels +// Parameters and tags // ============================================================================ /// The flow conditions as figure parameters (`figures::flow_condition_parameters`), as a map @@ -53,21 +53,21 @@ pub(crate) fn condition_parameters(conditions: &FlowConditions) -> BTreeMap Vec<&'static str> { - let mut labels = Vec::new(); +/// The regime tags of a set of flow conditions (`docs/development/conventions/figures.md`, "Tags") +pub(crate) fn regime_tags(conditions: &FlowConditions) -> Vec<&'static str> { + let mut tags = Vec::new(); match conditions.re { - None => labels.push("inviscid"), - Some(re) if re < 1e5 => labels.push("low-re"), + None => tags.push("inviscid"), + Some(re) if re < 1e5 => tags.push("low-re"), Some(_) => {} } if conditions.mach > 0.0 { - labels.push("compressible"); + tags.push("compressible"); } if conditions.re.is_some() && conditions.x_trip.iter().any(|&x| x < 1.0) { - labels.push("forced-transition"); + tags.push("forced-transition"); } - labels + tags } /// The parameters of one operating point: its inputs (`alpha_deg`) and outputs (forces, @@ -125,8 +125,8 @@ pub(crate) fn shared_parameters(sets: &[BTreeMap]) -> BTreeMa .collect() } -/// The labels every list holds -pub(crate) fn shared_labels(sets: &[Vec<&'static str>]) -> Vec<&'static str> { +/// The tags every list holds +pub(crate) fn shared_tags(sets: &[Vec<&'static str>]) -> Vec<&'static str> { let Some((first, rest)) = sets.split_first() else { return Vec::new(); }; @@ -137,13 +137,13 @@ pub(crate) fn shared_labels(sets: &[Vec<&'static str>]) -> Vec<&'static str> { .collect() } -/// Set a figure's parameters and labels -pub(crate) fn describe(mut fig: Figure, parameters: BTreeMap, labels: &[&str]) -> Figure { +/// Set a figure's parameters and tags +pub(crate) fn describe(mut fig: Figure, parameters: BTreeMap, tags: &[&str]) -> Figure { for (name, value) in parameters { fig = fig.parameter(name, value); } - for label in labels { - fig = fig.label(*label); + for tag in tags { + fig = fig.tag(*tag); } fig } @@ -198,11 +198,12 @@ impl Default for AnalysisPlotConfig { /// Height of the analysis figure (mm): two distribution panels over a strip with the aerofoil const ANALYSIS_HEIGHT_MM: f64 = 120.0; -/// The analysis figure: −Cp (top) and Ue/U∞ (middle) over x/c, upper surface blue and lower -/// red, over a strip with the aerofoil outline; x is linked across the three. +/// The analysis figure: Cp (top, on a reversed axis so suction is up, the aerodynamic +/// convention) and Ue/U∞ (middle) over x/c, upper surface blue and lower red, over a strip with +/// the aerofoil outline; x is linked across the three. /// -/// Cp is drawn as −Cp because ironlab has no reversed axis (the aerodynamic convention puts -/// suction up). +/// The outline is a tile of its own rather than an inset under the Cp curve: ironlab has no inset +/// axes or second y axis, by decision (thclark/ironlab#80). pub fn analysis_figure(analysis: &AnalysisOutput, config: &AnalysisPlotConfig) -> Result { let (Some((x_upper, cp_upper, q_upper)), Some((x_lower, cp_lower, q_lower))) = (analysis.upper_surface(), analysis.lower_surface()) @@ -212,14 +213,11 @@ pub fn analysis_figure(analysis: &AnalysisOutput, config: &AnalysisPlotConfig) - analysis.foil ))); }; - let minus = |v: &[f64]| v.iter().map(|c| -c).collect::>(); - let (minus_cp_upper, minus_cp_lower) = (minus(&cp_upper), minus(&cp_lower)); - let g = &analysis.geometry; let chord = g.chord; let (x_lo, x_hi) = padded_range(g.x.iter(), 0.0, 0.0); let xlim = (x_lo - 0.05 * chord, x_hi + 0.1 * chord); - let cp_lim = padded_range(minus_cp_upper.iter().chain(&minus_cp_lower), 0.1, 1e-3); + let cp_lim = padded_range(cp_upper.iter().chain(&cp_lower), 0.1, 1e-3); let q_lim = padded_range(q_upper.iter().chain(&q_lower), 0.1, 1e-3); let y_lim = padded_range(g.y.iter(), 0.1, 1e-3); @@ -240,21 +238,23 @@ pub fn analysis_figure(analysis: &AnalysisOutput, config: &AnalysisPlotConfig) - .tiles(3, 1) .row_weights([3.0, 3.0, 1.0]) .title(Text::plain(title)); - fig = describe(fig, parameters, ®ime_labels(&analysis.conditions)); + fig = describe(fig, parameters, ®ime_tags(&analysis.conditions)); let (upper, lower) = (rgb(config.upper_color), rgb(config.lower_color)); let mut ax = fig.axes(0, 0); - ax.plot(&x_upper, &minus_cp_upper) - .display_name("Upper surface") + ax.plot(&x_upper, &cp_upper) + .label("Upper surface") .color(upper) .line_width(1.5); - ax.plot(&x_lower, &minus_cp_lower) - .display_name("Lower surface") + ax.plot(&x_lower, &cp_lower) + .label("Lower surface") .color(lower) .line_width(1.5); - ax.ylabel("$-C_p$") + ax.ylabel("$C_p$") .xlim(xlim.0, xlim.1) .ylim(cp_lim.0, cp_lim.1) + .yreverse(true) + .xtick_labels_hidden() .grid(true) .legend(LegendLocation::NorthEast); let cp_axes = ax.id(); @@ -265,6 +265,7 @@ pub fn analysis_figure(analysis: &AnalysisOutput, config: &AnalysisPlotConfig) - ax.ylabel("$U_e/U_\\infty$") .xlim(xlim.0, xlim.1) .ylim(q_lim.0, q_lim.1) + .xtick_labels_hidden() .grid(true); let ue_axes = ax.id(); @@ -453,19 +454,19 @@ pub fn polars_figure(series: &[PolarSeries], config: &PolarsPlotConfig) -> Resul } } } - let labels = shared_labels(&series.iter().map(|s| regime_labels(&s.conditions)).collect::>()); + let tags = shared_tags(&series.iter().map(|s| regime_tags(&s.conditions)).collect::>()); - // the key: up to four series to a row, its row sized to the rows of entries + // the key: up to four series to a row, in a row sized to its entries let key_columns = series.len().min(4); - let key_rows = series.len().div_ceil(4); let mut fig = Figure::new() .size_mm(figures::WIDTH_MM, POLAR_HEIGHT_MM) .tiles(3, 2) - .row_weights([3.0, 3.0, 0.4 + 0.5 * key_rows as f64]) + .fit_row(2) .title(Text::plain(title)); - fig = describe(fig, parameters, &labels); + fig = describe(fig, parameters, &tags); let key = fig - .panel_legend_span(2, 0, 1, 2) + .panel_span(2, 0, 1, 2) + .legend() .columns(LegendEntryColumns::Count(key_columns as u32)) .order(LegendEntryOrder::RowMajor) .id(); @@ -487,7 +488,7 @@ pub fn polars_figure(series: &[PolarSeries], config: &PolarsPlotConfig) -> Resul let marker = config.markers[i % config.markers.len()]; let (x, y) = extract(s); let mut line = ax.plot(x, y); - line.display_name(Text::plain(s.label.clone())) + line.label(Text::plain(s.label.clone())) .color(color) .line_width(1.0) .marker(marker.shape) @@ -545,7 +546,7 @@ mod tests { assert!(shared.contains_key("mach")); assert_eq!(shared_parameters(std::slice::from_ref(&a)), a); assert_eq!( - shared_labels(&[vec!["inviscid", "compressible"], vec!["compressible"]]), + shared_tags(&[vec!["inviscid", "compressible"], vec!["compressible"]]), vec!["compressible"] ); } diff --git a/studies/README.md b/studies/README.md index 15bb8418..b8a3d5fd 100644 --- a/studies/README.md +++ b/studies/README.md @@ -23,5 +23,4 @@ Each study writes a dated run folder under its own `runs/` (gitignored), and nev | `xfoil-instrumentation-check/` | The instrumentation is inert on a polar through stall: pristine and instrumented builds compared byte for byte on XFOIL's own `PACC` and `DUMP` output | `cargo run --release -p xfoil-instrumentation-check` | its run folder only | Shared support, not studies: `study-support/records.rs` is the studies' whole-run floor comparison -(used by `branch-coverage` and `series-cases`), `study-support/publish.rs` writes a run's `publish/`, and `figures/` (`render.sh`, `style.py`) draws the -branch-coverage study's two matplotlib figures (needs [`uv`](https://docs.astral.sh/uv/)). +(used by `branch-coverage` and `series-cases`) and `study-support/publish.rs` writes a run's `publish/`. diff --git a/studies/aerofoil-series/Cargo.toml b/studies/aerofoil-series/Cargo.toml index 3e610775..7ce7707a 100644 --- a/studies/aerofoil-series/Cargo.toml +++ b/studies/aerofoil-series/Cargo.toml @@ -11,6 +11,6 @@ workspace = true [dependencies] yfoil = { path = "../..", default-features = false, features = ["plotting"] } -ironlab = "=0.12.0" +ironlab = "=0.16.0" cr-libm = { path = "../../cr-libm" } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/studies/aerofoil-series/src/sheet.rs b/studies/aerofoil-series/src/sheet.rs index 04d43498..bcc2030d 100644 --- a/studies/aerofoil-series/src/sheet.rs +++ b/studies/aerofoil-series/src/sheet.rs @@ -3,22 +3,18 @@ //! rerunning the study (`--render `). //! //! Layout: two columns of panels, one per parameter, each panel followed in the row below by its -//! own legend panel (row-major; the default's entry is last, because ironlab lists entries in -//! drawing order and the default is drawn on top). Every panel has the same linked axes -//! (`X_RANGE` × `Y_RANGE`); the members are drawn 0.75 pt in `figures::ylgnbu(i / (n − 1))` -//! (already ordered by parameter value), the family default black 1.5 pt on top. ironlab has no -//! equal-aspect axes, so the figure height and the row weights are chosen here so that the plot -//! areas come out close to equal scale at the default width. -//! -//! A legend has three columns of entries where they fit the panel's width and fewer where they do -//! not (a long default label, such as the Kármán–Trefftz one): the sheet is laid out, compiled by -//! ironlab, and any legend ironlab reports as overflowing is given one column fewer and a taller -//! row, until none overflows. +//! own legend panel. Every panel has the same linked axes (`X_RANGE` × `Y_RANGE`) at equal aspect +//! with the same ticks (`X_TICKS`, `Y_TICKS`), titled by the parameter it varies, aligned with its +//! plot area's left edge. The parameters it holds fixed title its legend: a panel title in full +//! ("position of maximum camber p (m = 0.04, t = 0.12)") is wider than a column at ironlab's +//! default font, and ironlab sets text on one line. +//! The members are drawn 0.75 pt in `figures::ylgnbu(i / (n − 1))` (already ordered by parameter +//! value), the family default black 1.5 pt on top. Each legend lists the default first, on a row of +//! its own, then the members in as many columns as fit the panel's width, in a row fitted to them. use std::path::{Path, PathBuf}; use ironlab::prelude::*; -use ironlab::SceneWarningKind; use serde_json::Value; use yfoil::output::figures; @@ -26,19 +22,14 @@ use crate::{X_RANGE, Y_RANGE}; /// Columns of panels const COLUMNS: usize = 2; -/// Columns of entries in a legend, where they fit -const LEGEND_COLUMNS: usize = 3; - -// Heights (mm) from which the figure height and the row weights are set. The plot area of a panel -// is the column width less the space ironlab reserves for the tick labels and the y label -// (PLOT_AREA_WIDTH_MM); its height follows from the equal-scale aspect, and the plot row adds the -// title above and the x tick labels and label below. Measured on an export at ironlab's defaults -// (160 mm wide, 9 pt). -const PLOT_AREA_WIDTH_MM: f64 = 63.5; -const PLOT_ROW_EXTRA_MM: f64 = 16.6; -const LEGEND_ENTRY_ROW_MM: f64 = 4.0; -const LEGEND_PAD_MM: f64 = 2.5; -const TITLE_MM: f64 = 8.0; +/// The ticks of every panel +const X_TICKS: [f64; 8] = [-0.2, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2]; +const Y_TICKS: [f64; 5] = [-0.4, -0.2, 0.0, 0.2, 0.4]; +/// The nominal height (mm) of a row of panels with its legend. The legend row is fitted to its +/// entries and the plot takes the rest; equal aspect then sizes each plot area within it. +const ROW_MM: f64 = 95.0; +/// The figure title (mm) +const TITLE_MM: f64 = 10.0; /// A geometry file's coordinates fn coordinates(path: &Path) -> (Vec, Vec) { @@ -58,38 +49,31 @@ fn f64_of(v: &Value, key: &str) -> f64 { .unwrap_or_else(|| panic!("summary.json: missing number {key}")) } +/// A panel title split into the parameter it varies and the ones it holds fixed: +/// "maximum camber m (p = 0.4, t = 0.12)" → ("maximum camber m", "p = 0.4, t = 0.12") +fn split_title(title: &str) -> (&str, Option<&str>) { + match title.rsplit_once(" (") { + Some((varied, held)) if held.ends_with(')') => (varied, Some(&held[..held.len() - 1])), + _ => (title, None), + } +} + fn members(panel: &Value) -> &Vec { panel["members"].as_array().expect("summary.json: members") } -/// The family sheet of one `summary.json` series entry, with `legend_columns[k]` columns of -/// entries in panel k's legend; returns the figure and the legend panels' ids -pub fn family_sheet(run_dir: &Path, series: &Value, n_nodes: i64, legend_columns: &[usize]) -> (Figure, Vec) { +/// The family sheet of one `summary.json` series entry +pub fn family_sheet(run_dir: &Path, series: &Value, n_nodes: i64) -> Figure { let slug = str_of(series, "slug"); let panels = series["panels"].as_array().expect("summary.json: panels"); let default = &series["default"]; let rows = panels.len().div_ceil(COLUMNS); - // row weights: a plot row, then the legend row its panels' legends need - let plot_area_h = PLOT_AREA_WIDTH_MM * (Y_RANGE.1 - Y_RANGE.0) / (X_RANGE.1 - X_RANGE.0); - let plot_row = plot_area_h + PLOT_ROW_EXTRA_MM; - let mut weights = Vec::with_capacity(2 * rows); - for r in 0..rows { - let entry_rows = (r * COLUMNS..((r + 1) * COLUMNS).min(panels.len())) - .map(|k| (members(&panels[k]).len() + 1).div_ceil(legend_columns[k])) - .max() - .unwrap_or(1); - weights.push(plot_row); - weights.push(entry_rows as f64 * LEGEND_ENTRY_ROW_MM + 2.0 * LEGEND_PAD_MM); - } - let height = weights.iter().sum::() + TITLE_MM; - let mut fig = Figure::new() - .size_mm(figures::WIDTH_MM, height) + .size_mm(figures::WIDTH_MM, rows as f64 * ROW_MM + TITLE_MM) .tiles((2 * rows) as u32, COLUMNS as u32) - .row_weights(weights) .title(Text::plain(str_of(series, "title"))) - .label("validation") + .tag("validation") .parameter("study", "aerofoil-series") .parameter("figure", "family-sheet") .parameter("series", slug) @@ -97,90 +81,72 @@ pub fn family_sheet(run_dir: &Path, series: &Value, n_nodes: i64, legend_columns .parameter("designation", str_of(default, "designation")) .parameter("max_thickness", f64_of(default, "max_thickness")) .parameter("te_gap", f64_of(default, "te_gap")); + for r in 0..rows { + fig = fig.fit_row((2 * r + 1) as u32); + } let (default_x, default_y) = coordinates(&run_dir.join(slug).join(str_of(default, "file"))); let default_label = format!("{} (default)", str_of(default, "label")); let mut axes = Vec::with_capacity(panels.len()); - let mut legends = Vec::with_capacity(panels.len()); for (k, panel) in panels.iter().enumerate() { let (r, c) = ((k / COLUMNS) as u32, (k % COLUMNS) as u32); - let key = { - let mut legend = fig.panel_legend(2 * r + 1, c); - legend - .columns(LegendEntryColumns::Count(legend_columns[k] as u32)) - .order(LegendEntryOrder::RowMajor) - .align(LegendLocation::North) - .boxed(false); - legend.id() - }; let members = members(panel); let span = (members.len().max(2) - 1) as f64; let mut ax = fig.axes(2 * r, c); + let mut ids = Vec::with_capacity(members.len()); for (i, m) in members.iter().enumerate() { let (x, y) = coordinates(&run_dir.join(slug).join(str_of(panel, "slug")).join(str_of(m, "file"))); - ax.plot(&x, &y) - .display_name(Text::plain(str_of(m, "label"))) + let id = ax + .plot(&x, &y) + .label(Text::plain(str_of(m, "label"))) .color(figures::ylgnbu(i as f64 / span)) - .line_width(0.75); + .line_width(0.75) + .id(); + ids.push(id); } // the default last, so that it is drawn on top - ax.plot(&default_x, &default_y) - .display_name(Text::plain(default_label.clone())) + let default_id = ax + .plot(&default_x, &default_y) + .label(Text::plain(default_label.clone())) .color(Color::rgb(0.0, 0.0, 0.0)) - .line_width(1.5); - ax.title(Text::plain(str_of(panel, "title"))) + .line_width(1.5) + .id(); + let (varied, held) = split_title(str_of(panel, "title")); + ax.title(Text::plain(varied)) + .title_align(TitleAlign::Left) .xlabel("$x/c$") .ylabel("$y/c$") .xlim(X_RANGE.0, X_RANGE.1) .ylim(Y_RANGE.0, Y_RANGE.1) - .legend_in(key); + .xticks(X_TICKS) + .yticks(Y_TICKS) + .aspect(Aspect::Equal) + .aspect_adjust(AspectAdjust::Box); axes.push(ax.id()); - legends.push(key); + // listed first, on a row of its own, then the members + let key = { + let mut panel = fig.panel(2 * r + 1, c); + panel.align(PanelLocation::NorthWest); + if let Some(held) = held { + panel.title(Text::plain(held)).title_align(TitleAlign::Left); + } + let mut legend = panel.legend(); + legend.full_row(default_id); + for id in ids { + legend.item(id); + } + legend + .columns(LegendEntryColumns::Auto) + .order(LegendEntryOrder::RowMajor) + .boxed(false); + legend.id() + }; + fig.axes(2 * r, c).legend_in(key); } // the same x/c and y/c on every panel fig.link(Dim::X, &axes).expect("the panels belong to this figure"); fig.link(Dim::Y, &axes).expect("the panels belong to this figure"); - (fig, legends) -} - -/// The legends of a figure that ironlab reports as overflowing, compiling it through a PDF export -/// to `probe` -fn overflowing(fig: &Figure, probe: &Path) -> Vec { - let report = fig - .export_pdf(probe) - .unwrap_or_else(|e| panic!("{}: {e}", probe.display())); - report - .scene - .iter() - .filter(|w| matches!(w.kind, SceneWarningKind::LegendOverflow { .. })) - .filter_map(|w| w.node) - .collect() -} - -/// Draw one series' sheet to `.fig` and `.pdf`, with as many legend columns (up to -/// three) as fit -fn draw(run_dir: &Path, series: &Value, n_nodes: i64, stem: &Path) { - let n_panels = series["panels"].as_array().expect("summary.json: panels").len(); - let mut columns = vec![LEGEND_COLUMNS; n_panels]; - let probe = stem.with_extension("pdf"); - loop { - let (fig, legends) = family_sheet(run_dir, series, n_nodes, &columns); - let over = overflowing(&fig, &probe); - if over.is_empty() { - figures::save_with_pdf(&fig, stem).unwrap_or_else(|e| panic!("{e}")); - return; - } - for (k, id) in legends.iter().enumerate() { - if over.contains(id) { - assert!( - columns[k] > 1, - "{}: panel {k}'s legend overflows in one column", - stem.display() - ); - columns[k] -= 1; - } - } - } + fig } /// Draw every family sheet of a run folder (`.fig` and `.pdf`); returns the stems @@ -197,7 +163,7 @@ pub fn render(run_dir: &Path) -> Vec { .iter() .map(|s| { let stem = run_dir.join(str_of(s, "slug")); - draw(run_dir, s, n_nodes, &stem); + figures::save_with_pdf(&family_sheet(run_dir, s, n_nodes), &stem).unwrap_or_else(|e| panic!("{e}")); stem }) .collect() @@ -205,6 +171,15 @@ pub fn render(run_dir: &Path) -> Vec { #[cfg(test)] mod tests { + #[test] + fn titles_split_into_the_varied_and_the_held_parameters() { + assert_eq!( + super::split_title("position of maximum camber p (m = 0.04, t = 0.12)"), + ("position of maximum camber p", Some("m = 0.04, t = 0.12")) + ); + assert_eq!(super::split_title("family"), ("family", None)); + } + use ironlab::ir::Parameter; use ironlab::prelude::*; @@ -235,7 +210,7 @@ mod tests { Some(&Parameter::String(s.default.designation.clone())), "{file}" ); - assert!(fig.labels().iter().any(|l| l == "validation"), "{file}"); + assert!(fig.tags().iter().any(|l| l == "validation"), "{file}"); assert!(fig.validate().errors.is_empty(), "{file}"); assert_eq!(fig.ir().axes.len(), s.panels.len(), "{file}: one panel per parameter"); assert!( diff --git a/studies/aerofoil-series/src/study.rs b/studies/aerofoil-series/src/study.rs index 1f9e371f..2829fb20 100644 --- a/studies/aerofoil-series/src/study.rs +++ b/studies/aerofoil-series/src/study.rs @@ -120,7 +120,7 @@ pub fn series() -> Vec { ), panel( "camber-position", - "maximum-camber position p (m = 0.04, t = 0.12)", + "position of maximum camber p (m = 0.04, t = 0.12)", (1..=9) .map(|p| naca(&format!("4{p}12"), p as f64 / 10.0, format!("p = 0.{p}"))) .collect(), @@ -155,7 +155,7 @@ pub fn series() -> Vec { ), panel( "max-thickness-position", - "maximum-thickness position M (I = 3, t = 0.12)", + "position of maximum thickness M (I = 3, t = 0.12)", (2..=6) .map(|m| naca(&format!("0012-3{m}"), m as f64 / 10.0, format!("M = 0.{m}"))) .collect(), @@ -189,7 +189,7 @@ pub fn series() -> Vec { ), panel( "camber-position", - "maximum-camber position p (cl = 0.3, t = 0.18)", + "position of maximum camber p (cl = 0.3, t = 0.18)", (1..=5) .map(|p| naca(&format!("2{p}018"), p as f64 / 20.0, format!("p = {:.2}", p as f64 / 20.0))) .collect(), @@ -253,7 +253,7 @@ pub fn series() -> Vec { panels: vec![ panel( "family", - "family: minimum-pressure position (cl = 0.4, t = 0.15)", + "family: position of minimum pressure (cl = 0.4, t = 0.15)", (3..=7) .map(|f| naca(&format!("6{f}-415"), f as f64 / 10.0, format!("6{f}"))) .collect(), diff --git a/studies/branch-coverage/Cargo.toml b/studies/branch-coverage/Cargo.toml index 02bb69ca..750bbad6 100644 --- a/studies/branch-coverage/Cargo.toml +++ b/studies/branch-coverage/Cargo.toml @@ -10,7 +10,8 @@ description = "Branch-coverage study: the minimal case set covering every reacha workspace = true [dependencies] -yfoil = { path = "../..", default-features = false } +yfoil = { path = "../..", default-features = false, features = ["plotting"] } +ironlab = "=0.16.0" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } toml = "0.8" diff --git a/studies/branch-coverage/plot.py b/studies/branch-coverage/plot.py deleted file mode 100644 index 8833a898..00000000 --- a/studies/branch-coverage/plot.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -"""Draw the branch-coverage study's figures from a run folder's summary.json. - -Presentation only: `branch_map` lists every analysis-path subroutine with the status of each of -its branches, `matrix` the fraction of each subroutine's branches every case of the minimal set -takes. Run via studies/figures/render.sh branch-coverage . -""" -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "figures")) -import style # noqa: E402 -from matplotlib import colormaps # noqa: E402 -from matplotlib.patches import Rectangle # noqa: E402 - -STATUS_COLOUR = { - "cover": "#2ca02c", # taken by the finite set - "nonfinite": "#1f77b4", # taken by the non-finite set - "pathological": "#ff7f0e", # taken only in pathological cases - "unreachable": "#9a9a9a", # annotated unreachable - "loop_entry": "#9a9a9a", # never-taken DO-line edge: not reachable either - "open": "#d62728", # not taken -} -STATUS_LABEL = { - "cover": "taken by the finite set", - "nonfinite": "taken by the non-finite set", - "pathological": "taken only in pathological cases", - "unreachable": "not reachable", - "open": "not taken", -} -CELL_PT = 3.3 -ROW_PT = 5.4 -LABEL_PT = 78.0 -LEGEND_ROW_PT = 11.0 -LEGEND_ROWS = 2 -MARGIN_PT = 4.0 -FILE_GAP_PT = 3.0 - - -def branch_map(run_dir: Path, s: dict) -> None: - rows = [r for r in s["branch_map"] if len(r["statuses"]) > 0] - files = [] - for r in rows: - if r["file"] not in files: - files.append(r["file"]) - legend = LEGEND_ROW_PT * LEGEND_ROWS + 4 - height = legend + 2 * MARGIN_PT + ROW_PT * (len(rows) + len(files)) + FILE_GAP_PT * len(files) - fig = style.figure(style.TEXT_WIDTH_PT, height) - ax = style.axes(fig, MARGIN_PT, MARGIN_PT + legend, style.TEXT_WIDTH_PT - 2 * MARGIN_PT, - height - legend - 2 * MARGIN_PT) - ax.set_axis_off() - ax.set_xlim(0, style.TEXT_WIDTH_PT - 2 * MARGIN_PT) - ax.set_ylim(0, height - legend - 2 * MARGIN_PT) - ax.grid(False) - y = height - legend - 2 * MARGIN_PT - current_file = None - for r in rows: - if r["file"] != current_file: - current_file = r["file"] - y -= FILE_GAP_PT - ax.text(0, y - ROW_PT * 0.15, current_file, fontsize=style.TICK_PT, fontweight="bold", - ha="left", va="top", family="monospace") - y -= ROW_PT - y -= ROW_PT - ax.text(LABEL_PT - 4, y + ROW_PT / 2, r["name"], fontsize=style.TICK_PT - 1.5, ha="right", - va="center", family="monospace") - for i, st in enumerate(r["statuses"]): - ax.add_patch(Rectangle((LABEL_PT + i * CELL_PT, y + 0.9), CELL_PT - 0.6, ROW_PT - 1.8, - facecolor=STATUS_COLOUR[st], edgecolor="none")) - # legend, LEGEND_ROWS rows; DO-line edges share the "not reachable" entry - lax = style.axes(fig, MARGIN_PT, MARGIN_PT, style.TEXT_WIDTH_PT - 2 * MARGIN_PT, legend) - lax.set_axis_off() - lax.grid(False) - lax.set_xlim(0, style.TEXT_WIDTH_PT - 2 * MARGIN_PT) - lax.set_ylim(0, legend) - keys = ["cover", "nonfinite", "pathological", "unreachable", "open"] - per_row = -(-len(keys) // LEGEND_ROWS) - col_w = (style.TEXT_WIDTH_PT - 2 * MARGIN_PT) / per_row - for n, key in enumerate(keys): - x = (n % per_row) * col_w - yy = legend - 4 - (n // per_row) * LEGEND_ROW_PT - LEGEND_ROW_PT / 2 - lax.add_patch(Rectangle((x, yy - 4), 8, 8, facecolor=STATUS_COLOUR[key], edgecolor="none")) - lax.text(x + 11, yy, STATUS_LABEL[key], fontsize=style.LEGEND_PT, va="center") - for ext in ("svg", "pdf"): - fig.savefig(run_dir / f"branch-map.{ext}") - - -def case_matrix(run_dir: Path, s: dict) -> None: - """Subroutines as rows, the cases of the set as columns.""" - m = s["matrix"] - cases = m["cases"] - keep = [i for i, row in enumerate(s["branch_map"]) if len(row["statuses"]) > 0] - subs = [m["subroutines"][i] for i in keep] - frac = [[m["fraction"][j][i] for i in keep] for j in range(len(cases))] - unique = {c["name"]: set(c["unique_by_subroutine"].keys()) for c in s["cases"]} - # three numeric columns to the right of the grid, per subroutine: every branch, the reachable - # ones (not annotated unreachable, not a never-taken DO-line edge), and those taken on any - # measured run (the finite set, the non-finite set and any measured pathological case) - rows = {r["key"]: r for r in s["subroutines"]} - counts = [] - for key in subs: - r = rows[key] - total = r["branches"] - reachable = total - r["unreachable"] - r["loop_entry"] - taken_all = r["cover"] + r["nonfinite"] + r["pathological"] - counts.append((total, reachable, taken_all)) - col_names = ["branches", "reachable", "taken, all cases"] - col_x = [16.0, 46.0, 76.0] - cols_w = 84.0 - cell_h = 7.2 - left, top, bottom = 62.0, 168.0, 50.0 - width = style.TEXT_WIDTH_PT - cell_w = (width - left - 4 - cols_w) / max(1, len(cases)) - height = top + bottom + cell_h * len(subs) - fig = style.figure(width, height) - ax = style.axes(fig, left, bottom, cell_w * len(cases), cell_h * len(subs)) - ax.grid(False) - ax.set_xlim(0, len(cases)) - ax.set_ylim(0, len(subs)) - cmap = colormaps["YlGnBu"] - for i, key in enumerate(subs): - yy = len(subs) - 1 - i - for j, name in enumerate(cases): - f = frac[j][i] - colour = cmap(0.15 + 0.85 * f) if f > 0 else "#f4f4f4" - ax.add_patch(Rectangle((j, yy), 1, 1, facecolor=colour, edgecolor="white", linewidth=0.4)) - if key.split(":")[1] in unique.get(name, set()): - ax.plot(j + 0.5, yy + 0.5, marker="o", markersize=2.2, color="black") - ax.set_yticks([len(subs) - 1 - i + 0.5 for i in range(len(subs))]) - ax.set_yticklabels([k.split(":")[1] for k in subs], fontsize=style.TICK_PT - 2, family="monospace") - ax.set_xticks([j + 0.5 for j in range(len(cases))]) - ax.set_xticklabels(cases, rotation=90, fontsize=style.TICK_PT - 1.5, family="monospace") - ax.xaxis.tick_top() - ax.tick_params(length=0) - for sp in ax.spines.values(): - sp.set_visible(False) - # the numeric columns - nax = style.axes(fig, left + cell_w * len(cases) + 2, bottom, cols_w, cell_h * len(subs) + top) - nax.set_axis_off() - nax.grid(False) - nax.set_xlim(0, cols_w) - nax.set_ylim(0, cell_h * len(subs) + top) - for x, name in zip(col_x, col_names): - nax.text(x, cell_h * len(subs) + 4, name, rotation=90, fontsize=style.TICK_PT - 1.5, ha="center", - va="bottom", family="monospace") - for i, (total, reachable, taken_all) in enumerate(counts): - yy = (len(subs) - 1 - i + 0.5) * cell_h - for x, v in zip(col_x, (total, reachable, taken_all)): - nax.text(x + 8, yy, str(v), fontsize=style.TICK_PT - 2, ha="right", va="center", family="monospace") - # key: the colour scale, the "not entered" cell and the unique-branch dot - key_y = 16.0 - bar_w = 150.0 - cax = style.axes(fig, left, key_y, bar_w, 7.0) - cax.grid(False) - for sp in cax.spines.values(): - sp.set_visible(False) - steps = 40 - for k in range(steps): - f = (k + 0.5) / steps - cax.add_patch(Rectangle((k / steps, 0), 1 / steps, 1, facecolor=cmap(0.15 + 0.85 * f), edgecolor="none")) - cax.set_xlim(0, 1) - cax.set_ylim(0, 1) - cax.set_yticks([]) - cax.set_xticks([0, 0.25, 0.5, 0.75, 1.0]) - cax.set_xticklabels(["0", "0.25", "0.5", "0.75", "1"], fontsize=style.TICK_PT - 1) - cax.tick_params(length=2, pad=1) - fig.text((left) * style.PT / fig.get_size_inches()[0], (key_y + 20) * style.PT / fig.get_size_inches()[1], - "fraction of the subroutine's branches the case took", fontsize=style.LEGEND_PT, va="center") - kax = style.axes(fig, left + bar_w + 24, key_y - 2, width - left - bar_w - 30, 24) - kax.set_axis_off() - kax.grid(False) - kax.set_xlim(0, width - left - bar_w - 30) - kax.set_ylim(0, 24) - kax.add_patch(Rectangle((0, 12), 9, 9, facecolor="#f4f4f4", edgecolor="#cccccc", linewidth=0.4)) - kax.text(13, 16.5, "subroutine not entered by the case", fontsize=style.LEGEND_PT, va="center") - kax.add_patch(Rectangle((0, 0), 9, 9, facecolor=cmap(0.6), edgecolor="none")) - kax.plot(4.5, 4.5, marker="o", markersize=2.2, color="black") - kax.text(13, 4.5, "takes a branch no other finite candidate takes", - fontsize=style.LEGEND_PT, va="center") - for ext in ("svg", "pdf"): - fig.savefig(run_dir / f"case-matrix.{ext}") - - -def main() -> None: - run_dir = Path(sys.argv[1]) - style.apply() - s = json.loads((run_dir / "summary.json").read_text()) - branch_map(run_dir, s) - case_matrix(run_dir, s) - print(f"figures written to {run_dir}") - - -if __name__ == "__main__": - main() diff --git a/studies/branch-coverage/src/figures.rs b/studies/branch-coverage/src/figures.rs new file mode 100644 index 00000000..313fbc68 --- /dev/null +++ b/studies/branch-coverage/src/figures.rs @@ -0,0 +1,258 @@ +//! The study's two occupancy figures, built from `summary.json` as ironlab figures +//! (`docs/development/conventions/figures.md`): the branch map (every branch of the analysis path, +//! one row per subroutine grouped by source file, coloured by status) and the case × subroutine +//! matrix (the fraction of each subroutine's branches each case of the minimal set takes). +//! +//! The matplotlib case matrix carried three columns of counts beside its rows (branches, +//! reachable, taken over all cases); ironlab has no margin table yet (thclark/ironlab#121), so the +//! counts are the `subroutine-counts` table the pages include under the figure. + +use std::collections::BTreeSet; +use std::path::Path; + +use ironlab::prelude::*; +use serde_json::Value; +use yfoil::output::figures::{self, WIDTH_MM}; + +/// The branch statuses as the figures key them, in the categorical scale's order. The DO-line +/// edges that are never taken (`loop_entry`) are not reachable either, so they share that entry. +const STATUSES: [(&str, (u8, u8, u8), &str); 5] = [ + ("cover", (0x2c, 0xa0, 0x2c), "taken by the finite set"), + ("nonfinite", (0x1f, 0x77, 0xb4), "taken by the non-finite set"), + ("pathological", (0xff, 0x7f, 0x0e), "taken only in pathological cases"), + ("unreachable", (0x9a, 0x9a, 0x9a), "not reachable"), + ("open", (0xd6, 0x27, 0x28), "not taken"), +]; + +/// The colour of a cell whose subroutine the case never entered. +const NOT_ENTERED: Color = Color::rgb(0xf4 as f32 / 255.0, 0xf4 as f32 / 255.0, 0xf4 as f32 / 255.0); + +/// The height of a row of either figure (mm), set by its tick label. +const ROW_MM: f64 = 3.4; + +/// The number of colours of the listed YlGnBu colormap of the case matrix. +const RAMP: usize = 64; + +fn rgb((r, g, b): (u8, u8, u8)) -> Color { + Color::rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0) +} + +/// A name of code in monospace: LaTeX's reserved `_` escaped. +fn tt(name: &str) -> String { + format!(r"\texttt{{{}}}", name.replace('_', r"\_")) +} + +/// The index of a status in [`STATUSES`]. +fn status_index(status: &str) -> f64 { + let status = if status == "loop_entry" { "unreachable" } else { status }; + STATUSES + .iter() + .position(|(key, _, _)| *key == status) + .unwrap_or_else(|| panic!("unknown branch status {status}")) as f64 +} + +/// The rows of `branch_map` that have branches, in the study's (coverage.toml) order. +fn rows(s: &Value) -> Vec<&Value> { + s["branch_map"] + .as_array() + .expect("branch_map") + .iter() + .filter(|r| r["statuses"].as_array().is_some_and(|a| !a.is_empty())) + .collect() +} + +fn str_of<'a>(v: &'a Value, key: &str) -> &'a str { + v[key].as_str().unwrap_or_else(|| panic!("{key} in summary.json")) +} + +/// The branch map: one row of cells per subroutine of the analysis path, under a bold header per +/// source file, each cell one branch coloured by its status; the key is the status scale. +pub fn branch_map(s: &Value) -> Figure { + let rows = rows(s); + let mut files: Vec<(&str, Vec)> = Vec::new(); + for r in &rows { + let (file, name) = (str_of(r, "file"), str_of(r, "name")); + match files.last_mut() { + Some((f, members)) if *f == file => members.push(tt(name)), + _ => files.push((file, vec![tt(name)])), + } + } + let statuses: Vec> = rows + .iter() + .map(|r| { + r["statuses"] + .as_array() + .unwrap() + .iter() + .map(|st| status_index(st.as_str().unwrap())) + .collect() + }) + .collect(); + let branches: usize = statuses.iter().map(Vec::len).sum(); + let lines = rows.len() + 2 * files.len(); + // a row is as tall as its label needs and the widest row spans the width the labels leave, so + // the cells are not square: 110 square cells to a row would make the rows too thin to label + let height = 40.0 + ROW_MM * lines as f64; + + let mut fig = Figure::new() + .size_mm(WIDTH_MM, height) + .tiles(2, 1) + .fit_row(1) + .title("Branches of the analysis path by status") + .parameter("study", "branch-coverage") + .parameter("figure", "branch-map") + .parameter("subroutines", rows.len() as i64) + .parameter("branches", branches as i64) + .tag("validation"); + let status = fig + .categorical_scale(STATUSES.map(|(_, c, label)| (rgb(c), label))) + .id(); + let mut ax = fig.axes(0, 0); + let placed = ax.ycategory_groups(files.iter().map(|(f, m)| (*f, m.iter()))); + ax.occupancy(placed.place(&statuses)).color_scale(status); + ax.box_on(false); + fig.panel(1, 0) + .legend() + .item(status) + .columns(LegendEntryColumns::Count(3)); + fig +} + +/// The case × subroutine matrix: the fraction of each subroutine's branches each case of the +/// minimal set takes; a dot marks a subroutine in which the case takes a branch no other finite +/// candidate takes, and a subroutine the case never entered is keyed as such. +pub fn case_matrix(s: &Value) -> Figure { + let m = &s["matrix"]; + let cases: Vec<&str> = m["cases"] + .as_array() + .unwrap() + .iter() + .map(|c| c.as_str().unwrap()) + .collect(); + let all_subs: Vec<&str> = m["subroutines"] + .as_array() + .unwrap() + .iter() + .map(|c| c.as_str().unwrap()) + .collect(); + let keep: Vec = (0..all_subs.len()) + .filter(|&i| s["branch_map"][i]["statuses"].as_array().is_some_and(|a| !a.is_empty())) + .collect(); + let subs: Vec<&str> = keep.iter().map(|&i| all_subs[i]).collect(); + let name = |key: &str| key.split_once(':').map_or(key, |(_, n)| n).to_string(); + // a case that never entered a subroutine took none of its branches: every subroutine with a + // conditional takes at least one edge once entered, so a fraction of zero is "not entered" + let fraction = Matrix::from_fn(subs.len(), cases.len(), |row, col| { + let f = m["fraction"][col][keep[row]].as_f64().unwrap(); + if f > 0.0 { + f + } else { + f64::NAN + } + }); + let mut unique = (Vec::new(), Vec::new()); + for (col, case) in cases.iter().enumerate() { + let c = s["cases"] + .as_array() + .unwrap() + .iter() + .find(|c| c["name"].as_str() == Some(case)) + .unwrap_or_else(|| panic!("case {case} in summary.json")); + let subs_unique: BTreeSet<&str> = c["unique_by_subroutine"] + .as_object() + .map(|o| o.keys().map(String::as_str).collect()) + .unwrap_or_default(); + for (row, key) in subs.iter().enumerate() { + if subs_unique.contains(name(key).as_str()) { + unique.0.push(col as f64); + unique.1.push(row as f64); + } + } + } + + let height = 85.0 + ROW_MM * subs.len() as f64; + let mut fig = Figure::new() + .size_mm(WIDTH_MM, height) + .tiles(3, 1) + .fit_row(1) + .fit_row(2) + .title("Branches each case of the minimal finite set takes") + .parameter("study", "branch-coverage") + .parameter("figure", "case-matrix") + .parameter("cases", cases.len() as i64) + .parameter("subroutines", subs.len() as i64) + .tag("validation"); + let ramp: Vec = (0..RAMP) + .map(|k| figures::ylgnbu(k as f64 / (RAMP - 1) as f64)) + .collect(); + let taken = fig + .color_scale() + .colormap(ironlab::ir::Colormap::Listed { colors: ramp }) + .limits(0.0, 1.0) + .tick_step(0.25) + .non_finite(NOT_ENTERED) + .key_label(ColorScaleKey::NonFinite, "subroutine not entered by the case") + .show_key(ColorScaleKey::NonFinite) + .title("Fraction of the subroutine's branches the case took") + .id(); + let key = fig.panel(2, 0).legend().boxed(false).id(); + let mut ax = fig.axes(0, 0); + ax.cells(&fraction) + .color_scale(taken) + .edge_color(Color::WHITE) + .edge_width(0.4); + ax.scatter(&unique.0, &unique.1) + .label("takes a branch no other finite candidate takes") + .marker(Marker::Circle) + .marker_size(4.0) + .marker_face(Color::WHITE) + .marker_edge(Color::BLACK); + ax.xcategories(cases.iter().map(|c| tt(c))) + .xtick_label_angle(90.0) + .ycategories(subs.iter().map(|k| tt(&name(k)))) + .box_on(false) + .legend_in(key); + fig.panel(1, 0) + .colorbar(taken) + .orientation(ColorbarOrientation::Horizontal) + .title_placement(ColorbarTitlePlacement::Above) + .length(0.8); + fig +} + +/// The per-subroutine counts the matplotlib case matrix carried beside its rows: every branch, +/// the reachable ones (not annotated unreachable, not a never-taken DO-line edge) and those taken +/// on any measured run (the finite set, the non-finite set and the measured pathological cases). +pub fn subroutine_counts_md(s: &Value) -> String { + let mut t = + String::from("| file | subroutine | branches | reachable | taken, all cases |\n|---|---|---:|---:|---:|\n"); + for r in s["subroutines"].as_array().unwrap() { + let n = |k: &str| r[k].as_u64().unwrap_or(0); + if n("branches") == 0 { + continue; + } + t += &format!( + "| `{}` | `{}` | {} | {} | {} |\n", + str_of(r, "file"), + str_of(r, "name"), + n("branches"), + n("branches") - n("unreachable") - n("loop_entry"), + n("cover") + n("nonfinite") + n("pathological"), + ); + } + t +} + +/// Draw both figures into the run folder (`.fig` and `.pdf`). A figure that does not validate or +/// lay out is reported and not written, and the publish check then fails on the page that embeds +/// it. +pub fn render(run_dir: &Path, s: &Value) -> bool { + let mut ok = true; + for (name, fig) in [("branch-map", branch_map(s)), ("case-matrix", case_matrix(s))] { + if let Err(e) = figures::save_with_pdf(&fig, &run_dir.join(name)) { + eprintln!("figure not drawn: {e}"); + ok = false; + } + } + ok +} diff --git a/studies/branch-coverage/src/main.rs b/studies/branch-coverage/src/main.rs index 6f66bd31..0736fd02 100644 --- a/studies/branch-coverage/src/main.rs +++ b/studies/branch-coverage/src/main.rs @@ -35,9 +35,8 @@ //! (`studies/known-issues`). //! //! Every invocation creates `runs//` next to this crate with `metadata.json`, -//! `summary.json` (every number of the tables and figures), `README.md` / `README.tex`; the -//! figures are drawn from `summary.json` by `plot.py` through `studies/figures/render.sh` -//! (matplotlib, presentation only). Every run also writes `publish/`, its tables and figures as +//! `summary.json` (every number of the tables and figures), `README.md` / `README.tex`, and the +//! two ironlab figures drawn from `summary.json` (`figures.rs`; `.fig` and `.pdf`). Every run also writes `publish/`, its tables and figures as //! the docs hold them (`studies/study-support/publish.rs`); `cargo xtask publish branch-coverage` //! copies it into `docs/validation/branch-coverage/`, whose pages are written by hand. //! `--render ` redraws an existing run folder's figures and rewrites its index and @@ -53,6 +52,7 @@ mod cover; mod drive; +mod figures; mod report; mod utilities; @@ -273,26 +273,6 @@ fn git(root: &Path, args: &[&str]) -> Option { .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) } -/// Draw the run folder's figures with `studies/figures/render.sh branch-coverage `. -pub fn render(run_dir: &Path) -> bool { - let root = repo_root(); - let status = std::process::Command::new(root.join("studies/figures/render.sh")) - .arg("branch-coverage") - .arg(run_dir) - .status(); - match status { - Ok(st) if st.success() => true, - other => { - eprintln!( - "figures not drawn ({}): run `studies/figures/render.sh branch-coverage {}`", - other.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string()), - run_dir.display() - ); - false - } - } -} - fn main() { let args: Vec = std::env::args().skip(1).collect(); assert!( @@ -307,7 +287,7 @@ fn main() { .expect("parse summary.json"); report::write_index(&run_dir, &summary); report::write_index_tex(&run_dir, &summary); - render(&run_dir); + figures::render(&run_dir, &summary); report::write_publish(&run_dir, &summary); return; } @@ -494,7 +474,7 @@ fn main() { .unwrap(); report::write_index(&run_dir, &summary); report::write_index_tex(&run_dir, &summary); - render(&run_dir); + figures::render(&run_dir, &summary); report::write_publish(&run_dir, &summary); println!("run folder: {}", run_dir.display()); if !group_matches { diff --git a/studies/branch-coverage/src/report.rs b/studies/branch-coverage/src/report.rs index d3cc13d5..1b3bd10e 100644 --- a/studies/branch-coverage/src/report.rs +++ b/studies/branch-coverage/src/report.rs @@ -806,15 +806,13 @@ fn body_md(s: &Value, figures: bool, figure_prefix: &str) -> String { asserted), one OPER script, one instrumented run plus its five 1-ULP twins.\n\n"; if figures { m += &format!( - "![Branch map]({figure_prefix}branch-map.svg)\n\n*Every branch of the analysis path, one row per \ - subroutine in XFOIL's file order: taken by the finite set (green), taken by the non-finite set \ - (blue), taken only in pathological cases (orange), not reachable — annotated unreachable or a \ - never-taken DO-line edge (grey), not taken (red).*\n\n\ - ![Case × subroutine]({figure_prefix}case-matrix.svg)\n\n*Fraction of each subroutine's branches \ - taken by each case of the set; a dot marks a subroutine in which the case takes a branch no other \ - finite candidate takes. The three columns on the right count, per subroutine, every branch, \ - the reachable ones (not annotated unreachable and not a never-taken DO-line edge) and those taken \ - on any measured run, the non-finite set included.*\n\n" + "[Branch map]({figure_prefix}branch-map.fig) ([PDF]({figure_prefix}branch-map.pdf)): every branch of \ + the analysis path, one row per subroutine in XFOIL's file order: taken by the finite set (green), \ + taken by the non-finite set (blue), taken only in pathological cases (orange), not reachable — \ + annotated unreachable or a never-taken DO-line edge (grey), not taken (red).\n\n\ + [Case × subroutine]({figure_prefix}case-matrix.fig) ([PDF]({figure_prefix}case-matrix.pdf)): \ + fraction of each subroutine's branches taken by each case of the set; a dot marks a subroutine in \ + which the case takes a branch no other finite candidate takes.\n\n" ); } m += "## DO loops of the analysis path\n\n"; @@ -889,7 +887,7 @@ pub fn write_index(run_dir: &Path, s: &Value) { let mut m = String::from("# Branch-coverage study\n\n"); m += &format!( "Generated by `cargo run --release -p branch-coverage` on {} (git {}{}, host {}); provenance in \ - `metadata.json`, every number in `summary.json`, figures drawn by `plot.py`.\n\n", + `metadata.json`, every number in `summary.json`, figures drawn from it by `figures.rs` (ironlab).\n\n", meta["generated"].as_str().unwrap_or(""), meta["git_describe"].as_str().unwrap_or(""), if meta["git_dirty"].as_bool().unwrap_or(false) { @@ -1075,11 +1073,11 @@ pub fn write_index_tex(run_dir: &Path, s: &Value) { ); w(&mut f, "\\noindent Every count is gcov's on the pristine double-precision reference; yFoil is never instrumented, and a branch taken by the finite set is one the reference took on a run of the set that yFoil matches as a whole run. \\emph{Taken by the non-finite set}: taken only on runs whose reference output contains NaN --- such a run cannot be compared whole, so each branch is tested as an event, replayed from XFOIL's state at that call, NaN where XFOIL has NaN. \\emph{Taken only in pathological cases}: taken only by the runs that hang or take hours, which cannot be tests. \\emph{Not reachable}: a recorded, falsifiable code-reading claim that no execution of XFOIL's analysis path can take the edge (structural, mode, guard, compiler or numerical: a floating-point coincidence or an exhausted Newton cap), or the never-taken arm of the loop-control test on a DO line. \\emph{Not taken}: no measured run took the edge and no argument says it cannot be taken; each carries a reach note. The Markdown page carries the full definitions."); w(&mut f, "\\begin{figure}[h]\\centering\\includegraphics{branch-map.pdf}\\caption{Every branch of the analysis path, one row per subroutine in XFOIL's file order: taken by the finite set (green), taken by the non-finite set (blue), taken only in pathological cases (orange), not reachable (grey), not taken (red).}\\end{figure}"); - w(&mut f, "\\begin{figure}[h]\\centering\\includegraphics{case-matrix.pdf}\\caption{Fraction of each subroutine's branches taken by each case of the set; a dot marks a subroutine in which the case takes a branch no other finite candidate takes; the columns on the right count every branch of the subroutine, the reachable ones (not annotated unreachable, not a never-taken DO-line edge) and those taken on any measured run, the non-finite probes included.}\\end{figure}"); + w(&mut f, "\\begin{figure}[h]\\centering\\includegraphics{case-matrix.pdf}\\caption{Fraction of each subroutine's branches taken by each case of the set; a dot marks a subroutine in which the case takes a branch no other finite candidate takes; a subroutine the case never entered is light grey.}\\end{figure}"); } /// The run's docs bundle (`publish/`): one fragment per table the study's pages include, and the -/// two SVG figures. +/// two figures. pub fn write_publish(run_dir: &Path, s: &Value) { let meta: Value = serde_json::from_str(&fs::read_to_string(run_dir.join("metadata.json")).unwrap()).unwrap(); let u = &s["universe"]; @@ -1133,7 +1131,8 @@ pub fn write_publish(run_dir: &Path, s: &Value) { ), ); bundle.table("non-finite-set", &excluded_md(s)); - for name in ["branch-map.svg", "case-matrix.svg"] { + bundle.table("subroutine-counts", &crate::figures::subroutine_counts_md(s)); + for name in ["branch-map.fig", "case-matrix.fig"] { bundle.figure(&run_dir.join(name)); } println!( diff --git a/studies/figures/render.sh b/studies/figures/render.sh deleted file mode 100755 index 601bfc96..00000000 --- a/studies/figures/render.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# -# Draw the branch-coverage study's occupancy figures (branch map, case matrix) from its run folder -# with matplotlib: the only figures ironlab cannot yet express (docs/development/conventions/figures.md; every -# other figure is an ironlab figure built in Rust). -# -# studies/figures/render.sh e.g. branch-coverage studies/branch-coverage/runs/2026-10-15T14-17-40Z -# -# The study's Rust program writes every number into the run folder; studies//plot.py only -# presents them (studies/figures/style.py holds the shared conventions). matplotlib is pinned here -# and fetched by uv into its own cache on first use, as scripts/docs.sh does for zensical; there is -# no Python environment in the working tree. -set -euo pipefail - -matplotlib_version="3.11.1" - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -study="${1:?usage: render.sh }" -run_dir="${2:?usage: render.sh }" - -if ! command -v uv >/dev/null 2>&1; then - echo "error: uv is not installed. Install it from https://docs.astral.sh/uv/ and re-run." >&2 - exit 1 -fi - -cd "$repo_root" -exec uv run --no-project --quiet --with "matplotlib==$matplotlib_version" \ - python "studies/$study/plot.py" "$run_dir" diff --git a/studies/figures/style.py b/studies/figures/style.py deleted file mode 100644 index cc89e3f3..00000000 --- a/studies/figures/style.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Presentation rules of the branch-coverage study's occupancy figures (branch map, case matrix), -the only figures still drawn by matplotlib; every other figure is an ironlab figure built in Rust -(docs/development/conventions/figures.md). - -The Rust study writes every number (data, derived quantities, extents, selections) into its run -folder; the plot script only presents them: - -- figures are drawn at their final physical size and go into the paper at natural size - (`\\includegraphics{...pdf}` with no `width=` and no `\\resizebox`); -- width is TEXT_WIDTH_PT, the `\\textwidth` of the target template (A4 with 1 in margins), or less; -- Times New Roman throughout; tick values and legends at 8 pt (the journal minimum), axis labels - at 9 pt; math through mathtext in a Times-compatible face; -- SVG with text kept as text, and PDF with the fonts embedded, from the same figure object. - -Run through studies/figures/render.sh, which pins matplotlib. -""" - - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt # noqa: E402 - -TEXT_WIDTH_PT = 451.0 -FONT = "Times New Roman" -TICK_PT = 8.0 -LEGEND_PT = 8.0 -AXIS_LABEL_PT = 9.0 -PT = 1.0 / 72.0 # inches per point -GRID_COLOUR = "#e4e4e4" - -def apply() -> None: - plt.rcParams.update( - { - "font.family": "serif", - "font.serif": [FONT, "Times", "Nimbus Roman", "DejaVu Serif"], - "mathtext.fontset": "stix", - "font.size": TICK_PT, - "axes.labelsize": AXIS_LABEL_PT, - "axes.titlesize": AXIS_LABEL_PT, - "xtick.labelsize": TICK_PT, - "ytick.labelsize": TICK_PT, - "legend.fontsize": LEGEND_PT, - "axes.linewidth": 0.75, - "xtick.major.width": 0.75, - "ytick.major.width": 0.75, - "axes.spines.top": False, - "axes.spines.right": False, - "axes.grid": True, - "grid.color": GRID_COLOUR, - "grid.linewidth": 0.5, - "axes.axisbelow": True, - "svg.fonttype": "none", - "pdf.fonttype": 42, - "figure.dpi": 100, - } - ) - - -def figure(width_pt: float, height_pt: float): - return plt.figure(figsize=(width_pt * PT, height_pt * PT)) - - -def axes(fig, x0_pt: float, y0_pt: float, w_pt: float, h_pt: float): - """An axes placed in points from the figure's bottom-left corner.""" - fw, fh = fig.get_size_inches() - return fig.add_axes([x0_pt * PT / fw, y0_pt * PT / fh, w_pt * PT / fw, h_pt * PT / fh]) diff --git a/studies/input-sensitivity/Cargo.toml b/studies/input-sensitivity/Cargo.toml index 7e870b8b..82f9455b 100644 --- a/studies/input-sensitivity/Cargo.toml +++ b/studies/input-sensitivity/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] yfoil = { path = "../..", default-features = false, features = ["plotting"] } -ironlab = "=0.12.0" +ironlab = "=0.16.0" cr-libm = { path = "../../cr-libm" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/studies/input-sensitivity/src/figures.rs b/studies/input-sensitivity/src/figures.rs index 9ca56824..6f16f169 100644 --- a/studies/input-sensitivity/src/figures.rs +++ b/studies/input-sensitivity/src/figures.rs @@ -3,14 +3,15 @@ //! //! Seven stacked rows share α — CL, CD, δ* and H at the upper-surface trailing edge, the //! upper-surface transition and trailing-edge separation x/c, and the final RMSBL on a log axis — -//! one column per input family. The base case is black, 1.5 pt, with filled markers, drawn last -//! so it lies on top; the perturbation levels are drawn behind it, largest first, coloured by -//! perturbation size (`figures::ylgnbu`, lightest = smallest). Unconverged points carry open -//! circles. The axes span the converged extents `metrics.json` records; a value beyond them (an +//! one column per input family; only the bottom row carries α tick labels. The base case is +//! black, 1.5 pt, with filled markers, drawn last so it lies on top, and listed first in the key +//! on a row of its own; the perturbation levels are drawn behind it and listed after it by +//! increasing perturbation, coloured by perturbation size (`figures::ylgnbu`, lightest = +//! smallest). Unconverged points carry open circles. The axes span the converged extents `metrics.json` records; a value beyond them (an //! unconverged state can be anything, up to CD = 1e19) is drawn on the axis edge, and a value //! that cannot be drawn (non-finite, or non-positive on the log axis) is left out. Each column -//! has a title and its own legend panel below it. The node and alpha-step families share a -//! two-column sheet; every family also gets a one-column figure. +//! has a title and its own legend panel below it, in a row fitted to the tallest key. The node +//! and alpha-step families share a two-column sheet; every family also gets a one-column figure. //! //! Each figure is saved as `/naca_.fig` with its PDF beside it (`README.tex` //! includes the PDFs). @@ -92,9 +93,9 @@ const ROWS: [(&str, Get, &str, bool); 7] = [ ("rmsbl", |p| p.rmsbl, "RMSBL", true), ]; -/// Height (mm) of a unit row weight: a data row has weight 1, so the figure's height (at the -/// default width) follows from its rows and its legend -const ROW_HEIGHT_MM: f64 = 29.0; +/// Height (mm) of the figure per data row; the legend row is fitted to its key and the data rows +/// share the rest +const ROW_HEIGHT_MM: f64 = 32.0; /// Legend columns: two under a full-width column, one under each column of the sheet (two /// would not fit its width at the default font) const LEGEND_COLUMNS_FULL_WIDTH: u32 = 2; @@ -103,9 +104,6 @@ const BASE_WIDTH_PT: f64 = 1.5; const LEVEL_WIDTH_PT: f64 = 0.75; const MARKER_PT: f64 = 2.4; const OPEN_MARKER_PT: f64 = 4.4; -/// Height of one legend entry, and of the legend's insets, relative to a data row -const LEGEND_ENTRY_WEIGHT: f64 = 0.14; -const LEGEND_INSET_WEIGHT: f64 = 0.36; fn exponent(v: f64) -> i32 { -(cr_libm::log10(v).round() as i32) @@ -189,22 +187,13 @@ fn draw(run_dir: &Path, meta: &Metadata, metrics: &Metrics, families: &[Family], } else { LEGEND_COLUMNS_FULL_WIDTH }; - let legend_rows = families - .iter() - .map(|f| metrics.levels_by_family.get(f.slug()).map_or(0, Vec::len)) - .max() - .unwrap_or(0) - .div_ceil(legend_columns as usize); - let legend_weight = LEGEND_ENTRY_WEIGHT * legend_rows as f64 + LEGEND_INSET_WEIGHT; - let mut weights = vec![1.0; ROWS.len()]; - weights.push(legend_weight); - let height_mm = ROW_HEIGHT_MM * (ROWS.len() as f64 + legend_weight); + let height_mm = ROW_HEIGHT_MM * ROWS.len() as f64; let slugs: Vec<&str> = families.iter().map(|f| f.slug()).collect(); let mut fig = Figure::new() .size_mm(figures::WIDTH_MM, height_mm) .tiles(nrow + 1, ncol) - .row_weights(weights) + .fit_row(nrow) .parameter("study", "input-sensitivity") .parameter("figure", "sensitivity-sheet") .parameter("foil", meta.foil.as_str()) @@ -216,12 +205,12 @@ fn draw(run_dir: &Path, meta: &Metadata, metrics: &Metrics, families: &[Family], .parameter("ncrit", meta.base.ncrit) .parameter("max_iterations", meta.base.iter as i64) .parameter("alpha_step_deg", meta.base.alpha_step_deg) - .label("testing"); + .tag("testing"); if meta.base.mach > 0.0 { - fig = fig.label("compressible"); + fig = fig.tag("compressible"); } if meta.base.re < 1e5 { - fig = fig.label("low-re"); + fig = fig.tag("low-re"); } let mut row_axes: Vec> = vec![Vec::new(); ROWS.len()]; @@ -233,19 +222,16 @@ fn draw(run_dir: &Path, meta: &Metadata, metrics: &Metrics, families: &[Family], .ok_or_else(|| format!("metrics.json has no family {}", family.slug()))?; let levels = ordered(levels); let n = (levels.len().max(2) - 2).max(1) as f64; - let key = { - let mut panel = fig.panel_legend(nrow, c); - panel - .columns(LegendEntryColumns::Count(legend_columns)) - .order(LegendEntryOrder::RowMajor) - .align(LegendLocation::North); - panel.id() - }; + let key = fig.panel(nrow, c).legend().id(); + // the top row's artists, in key order (base first), which the key lists explicitly + let mut keyed: Vec<(bool, NodeId)> = Vec::new(); for (r, (quantity, get, label, log)) in ROWS.iter().enumerate() { let (lo, hi) = y_limits(extent(quantity)?, *log); let mut ax = fig.axes(r as u32, c); - // the stack, largest perturbation first so the base lands on top - for (i, level) in levels.iter().enumerate().rev() { + // the levels by increasing perturbation, then the base last so it lands on top + let (bases, rest): (Vec, Vec) = (0..levels.len()).partition(|&i| levels[i].magnitude == 0.0); + for i in rest.into_iter().chain(bases) { + let level = levels[i]; let is_base = level.magnitude == 0.0; let colour = if is_base { Color::BLACK @@ -267,10 +253,13 @@ fn draw(run_dir: &Path, meta: &Metadata, metrics: &Metrics, families: &[Family], let mut line = ax.plot(&a, &v); line.color(colour) .line_width(if is_base { BASE_WIDTH_PT } else { LEVEL_WIDTH_PT }) - .display_name(level_label(level, meta)); + .label(level_label(level, meta)); if is_base { line.marker(Marker::Circle).marker_size(MARKER_PT).marker_face(colour); } + if r == 0 { + keyed.push((is_base, line.id())); + } } let (ua, uv): (Vec, Vec) = drawn.iter().filter(|d| !d.2).map(|d| (d.0, d.1)).unzip(); if !ua.is_empty() { @@ -293,9 +282,23 @@ fn draw(run_dir: &Path, meta: &Metadata, metrics: &Metrics, families: &[Family], } if r + 1 == ROWS.len() { ax.xlabel("$\\alpha$ (°)"); + } else { + ax.xtick_labels_hidden(); } row_axes[r].push(ax.id()); } + // the key: the base alone on its first row, then the levels by increasing perturbation + let mut panel = fig.panel(nrow, c); + let mut legend = panel.legend(); + legend + .columns(LegendEntryColumns::Count(legend_columns)) + .order(LegendEntryOrder::RowMajor); + for &(_, id) in keyed.iter().filter(|(is_base, _)| *is_base) { + legend.full_row(id); + } + for &(_, id) in keyed.iter().filter(|(is_base, _)| !*is_base) { + legend.item(id); + } } fig.link_all_x().map_err(|e| format!("{e}"))?; if ncol > 1 { @@ -397,6 +400,7 @@ mod tests { for f in written { let fig = Figure::load(dir.join(&f)).unwrap(); assert!(fig.validate().errors.is_empty(), "{f}"); + assert!(fig.layout_warnings().is_empty(), "{f}: {:?}", fig.layout_warnings()); assert!(dir.join(f.replace(".fig", ".pdf")).exists()); } } diff --git a/studies/series-cases/Cargo.toml b/studies/series-cases/Cargo.toml index 526d7804..ded10929 100644 --- a/studies/series-cases/Cargo.toml +++ b/studies/series-cases/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] yfoil = { path = "../..", default-features = false, features = ["plotting"] } -ironlab = "=0.12.0" +ironlab = "=0.16.0" cr-libm = { path = "../../cr-libm" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/studies/series-cases/src/figures.rs b/studies/series-cases/src/figures.rs index 810779f8..10260695 100644 --- a/studies/series-cases/src/figures.rs +++ b/studies/series-cases/src/figures.rs @@ -7,6 +7,7 @@ use crate::{Case, Study}; use cr_libm::{log10, pow}; use ironlab::prelude::*; +use ironlab::{LineStyle, MarkerStyle}; use serde_json::Value; use std::path::Path; use yfoil::output::figures::{self, FigureError, BLUE, ORANGE, RED, WIDTH_MM}; @@ -14,6 +15,8 @@ use yfoil::output::figures::{self, FigureError, BLUE, ORANGE, RED, WIDTH_MM}; const PT_TO_MM: f64 = 25.4 / 72.0; /// XFOIL's α range for every polar of the study. const ALPHA_RANGE: (f64, f64) = (-30.0, 30.0); +/// Where a NaN tick sits: at its α, this fraction of the panel's height up, whatever the limits. +const NAN_TICK_HEIGHT: f64 = 0.985; /// Legend names: one entry per look, shared by every panel of a sheet. const XFOIL_NAME: &str = "XFOIL (reference)"; @@ -21,13 +24,17 @@ const YFOIL_NAME: &str = "yFoil"; const FOLLOWED: &str = "yFoil point: every iteration agrees with XFOIL's"; const INFORMATIONAL: &str = "yFoil point: parts from XFOIL within it, or after a point that did (informational)"; const NONFINITE: &str = "went through a non-finite value within the point"; -const NAN_TICK: &str = "plotted value is NaN (marker at the top of the panel at its α)"; +const NAN_TICK: &str = "plotted value is NaN (tick at the top of the panel at its α)"; const ILL: &str = "parts where XFOIL is ill-conditioned (its own 1-ULP twins part there too)"; const MISMATCH: &str = "parts where XFOIL is well-conditioned (its twins agree): a mismatch, replayed step by step"; const STATIONS: &str = "final stagnation or transition station (IST, ITRAN) differs between the codes"; -const TRANSITION_FOLLOWED: &str = "every iteration of the point agrees with XFOIL's"; -const TRANSITION_INFORMATIONAL: &str = "parts from XFOIL within the point, or after a point that did (informational)"; +// The strip's key is as wide as its two plot areas, which the case names leave narrow, so its +// entries are the short forms of the sheet's. +const TRANSITION_FOLLOWED: &str = "followed: every iteration agrees with XFOIL's"; +const TRANSITION_INFORMATIONAL: &str = "informational: parts from XFOIL"; +const TRANSITION_ILL: &str = "divergent: XFOIL ill-conditioned there"; +const TRANSITION_MISMATCH: &str = "mismatch: XFOIL well-conditioned there"; /// How one kind of mark is drawn and what the key calls it. struct Look { @@ -40,18 +47,19 @@ struct Look { edge_width: f64, } -/// Draw marks of one look as a marker-only series; nothing when there are none, so the key lists -/// only what the figure shows. A non-positive value on a logarithmic axis is not drawn. -fn draw(ax: &mut AxesMut<'_>, m: &Marks, look: &Look, log: bool) { +/// Draw marks of one look as a marker-only series, returning its id; nothing when there are none, +/// so the key lists only what the figure shows. A non-positive value on a logarithmic axis is not +/// drawn. +fn draw(ax: &mut AxesMut<'_>, m: &Marks, look: &Look, log: bool) -> Option { if m.x.is_empty() { - return; + return None; } let y: Vec = m.y.iter() .map(|&v| if log && v <= 0.0 { f64::NAN } else { v }) .collect(); let mut line = ax.plot(&m.x, &y); - line.display_name(look.name) + line.label(look.name) .color(look.edge) .dash(Dash::None) .line_width(look.edge_width) @@ -61,6 +69,75 @@ fn draw(ax: &mut AxesMut<'_>, m: &Marks, look: &Look, log: bool) { if let Some(f) = look.face { line.marker_face(f); } + Some(line.id()) +} + +/// The key of a sheet: the first artist of each name, in the order first drawn. One item stands +/// for every artist of its name and look, in every panel. +#[derive(Default)] +struct Key { + items: Vec<(&'static str, NodeId)>, +} + +impl Key { + fn add(&mut self, name: &'static str, id: Option) { + if let Some(id) = id { + if !self.items.iter().any(|(n, _)| *n == name) { + self.items.push((name, id)); + } + } + } +} + +/// Mark, at the top edge of a panel, the α of each value that is NaN: a vertical tick at data α +/// and an axes-fraction height, so it stays on the edge whatever the limits, pan or zoom, and +/// takes no part in them. It is unnamed: the key explains the ticks once ([`nan_tick_sample`]). +fn nan_ticks(ax: &mut AxesMut<'_>, alpha: &[f64]) { + if alpha.is_empty() { + return; + } + ax.plot(alpha, vec![NAN_TICK_HEIGHT; alpha.len()]) + .y_coords(Coords::AxesFraction) + .clip(false) + .color(ORANGE) + .dash(Dash::None) + .marker(Marker::VLine) + .marker_size(4.0) + .marker_edge(ORANGE); +} + +/// The key's sample for the NaN ticks, which stand for no single artist. +fn nan_tick_sample() -> LegendSample { + LegendSample::Line { + line: LineStyle { + color: ColorSpec::Rgba { color: ORANGE }, + dash: Dash::None, + ..LineStyle::default() + }, + marker: MarkerStyle { + shape: Marker::VLine, + size_pt: 4.0, + face: ColorSpec::None, + edge: ColorSpec::Rgba { color: ORANGE }, + }, + } +} + +/// A case name (a code identifier) in monospace, its LaTeX specials escaped. +fn mono(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 10); + for c in name.chars() { + if matches!(c, '_' | '{' | '}' | '$' | '&' | '%' | '#') { + out.push('\\'); + } + out.push(c); + } + format!("\\texttt{{{out}}}") +} + +/// Plain text with the one character that opens LaTeX math outside it escaped. +fn text(s: &str) -> String { + s.replace('$', "\\$") } fn grey_diamond() -> Color { @@ -82,9 +159,9 @@ fn describe(fig: Figure, study: &Study, figure: &str, case: &Case, run: &Value) if let Some([leg, alpha]) = run["parted"].as_array().map(Vec::as_slice) { fig = fig.parameter("parted", format!("leg {leg} α {alpha}°")); } - fig = fig.label("validation").label("xfoil-comparison"); + fig = fig.tag("validation").tag("xfoil-comparison"); for tag in regime_labels(case) { - fig = fig.label(tag); + fig = fig.tag(tag); } fig } @@ -207,16 +284,6 @@ fn limits(values: &[f64], log: bool, floor: Option) -> (f64, f64) { } } -/// Where a marker sits at the top of a panel: 1.5 % of the panel's height below its top edge. -fn near_top(lo: f64, hi: f64, log: bool) -> f64 { - if log { - let (l, h) = (log10(lo), log10(hi)); - pow(10.0, h - 0.015 * (h - l)) - } else { - hi - 0.015 * (hi - lo) - } -} - /// One case's polar sheet: CL and CD (top), the last RMSBL and the iterations (second row), the /// transition location on each side (third), the position of transition within its station /// interval on each side (fourth) and, where the fixture kept the reference's per-call state, @@ -239,37 +306,29 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { panels.push(panel("state_diff", "worst state array: $|\\Delta|$, floor (red)", true)); } let rows = panels.len().div_ceil(2) as u32; - // the matplotlib sheet's proportions: 118 pt panels 30 pt apart, a 107 pt legend strip + // the matplotlib sheet's proportions: 118 pt panels 30 pt apart, about 107 pt for the key, + // whose row is fitted to it let (panel_pt, gap_pt, legend_pt, frame_pt) = (118.0, 30.0, 107.0, 42.0); let height_pt = frame_pt + rows as f64 * panel_pt + (rows - 1) as f64 * gap_pt + legend_pt; - let row_weights: Vec = (0..rows) - .map(|_| 1.0) - .chain([legend_pt / (panel_pt + gap_pt)]) - .collect(); let mut fig = Figure::new() .size_mm(WIDTH_MM, height_pt * PT_TO_MM) .tiles(rows + 1, 2) - .row_weights(row_weights) - .title(Text::plain(format!( + .fit_row(rows) + .title(format!( "{}: {}", - run["section"].as_str().unwrap_or(""), - case.name - ))); + text(run["section"].as_str().unwrap_or("")), + mono(&case.name) + )); fig = describe(fig, study, "polar-sheet", case, run); - let key = { - let mut legend = fig.panel_legend_span(rows, 0, 1, 2); - legend - .boxed(false) - .columns(LegendEntryColumns::Count(1)) - .align(LegendLocation::West); - legend.id() - }; + let key_panel = fig.panel_span(rows, 0, 1, 2).align(PanelLocation::West).legend().id(); + let mut key = Key::default(); + let mut any_nan = false; let mut ids = vec![]; for (i, pn) in panels.iter().enumerate() { let (row, col) = ((i / 2) as u32, (i % 2) as u32); let mut all: Vec = vec![]; - let mut nan_ticks: Vec = vec![]; + let mut nan_ticks_at: Vec = vec![]; let mut ax = fig.axes(row, col); let usable = |v: Option| v.filter(|v| !pn.log || *v > 0.0); for leg in [1, 2] { @@ -289,7 +348,7 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { .map(|p| { let v = value(p, "xfoil", pn.key); match v { - None => nan_ticks.push(alpha(p)), + None => nan_ticks_at.push(alpha(p)), Some(y) if p["xfoil_nonfinite"].as_bool().unwrap_or(false) => xfoil_nonfinite.push(alpha(p), y), _ => {} } @@ -298,13 +357,16 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { .unzip(); if !xa.is_empty() { all.extend(&xy); - ax.plot(&xa, &xy) - .display_name(XFOIL_NAME) + let id = ax + .plot(&xa, &xy) + .label(XFOIL_NAME) .color(RED) .dash(Dash::Dashed) .line_width(0.9) .marker(Marker::Cross) - .marker_size(3.2); + .marker_size(3.2) + .id(); + key.add(XFOIL_NAME, Some(id)); } let mut followed = Marks::default(); let mut informational = Marks::default(); @@ -318,7 +380,7 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { let a = alpha(p); let v = value(p, "yfoil", pn.key); let Some(y) = v else { - nan_ticks.push(a); + nan_ticks_at.push(a); return (a, f64::NAN); }; let filled = p["followed"].as_bool().unwrap_or(false); @@ -343,7 +405,8 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { .unzip(); all.extend(&yy); if !ya.is_empty() { - ax.plot(&ya, &yy).display_name(YFOIL_NAME).color(BLUE).line_width(0.8); + let id = ax.plot(&ya, &yy).label(YFOIL_NAME).color(BLUE).line_width(0.8).id(); + key.add(YFOIL_NAME, Some(id)); } let look = |name, shape, size, edge, face| Look { name, @@ -353,114 +416,140 @@ pub fn polar_sheet(study: &Study, case: &Case, run: &Value) -> Figure { face, edge_width: 0.6, }; - draw( - &mut ax, - &xfoil_nonfinite, - &look(NONFINITE, Marker::Square, 5.2, ORANGE, None), - pn.log, + key.add( + NONFINITE, + draw( + &mut ax, + &xfoil_nonfinite, + &look(NONFINITE, Marker::Square, 5.2, ORANGE, None), + pn.log, + ), ); - draw( - &mut ax, - &followed, - &look(FOLLOWED, Marker::Circle, 2.4, BLUE, Some(BLUE)), - pn.log, + key.add( + FOLLOWED, + draw( + &mut ax, + &followed, + &look(FOLLOWED, Marker::Circle, 2.4, BLUE, Some(BLUE)), + pn.log, + ), ); - draw( - &mut ax, - &informational, - &look(INFORMATIONAL, Marker::Circle, 2.4, BLUE, None), - pn.log, + key.add( + INFORMATIONAL, + draw( + &mut ax, + &informational, + &look(INFORMATIONAL, Marker::Circle, 2.4, BLUE, None), + pn.log, + ), ); - draw( - &mut ax, - &yfoil_nonfinite, - &look(NONFINITE, Marker::Square, 5.2, ORANGE, None), - pn.log, + key.add( + NONFINITE, + draw( + &mut ax, + &yfoil_nonfinite, + &look(NONFINITE, Marker::Square, 5.2, ORANGE, None), + pn.log, + ), ); - draw( - &mut ax, - &ill, - &look(ILL, Marker::Diamond, 5.8, Color::BLACK, None), - pn.log, + key.add( + ILL, + draw( + &mut ax, + &ill, + &look(ILL, Marker::Diamond, 5.8, Color::BLACK, None), + pn.log, + ), ); let grey = grey_diamond(); - draw( - &mut ax, - &mismatch, - &look(MISMATCH, Marker::Diamond, 5.8, grey, Some(grey)), - pn.log, + key.add( + MISMATCH, + draw( + &mut ax, + &mismatch, + &look(MISMATCH, Marker::Diamond, 5.8, grey, Some(grey)), + pn.log, + ), ); - draw( - &mut ax, - &stations, - &look(STATIONS, Marker::TriangleDown, 6.5, Color::BLACK, None), - pn.log, + key.add( + STATIONS, + draw( + &mut ax, + &stations, + &look(STATIONS, Marker::TriangleDown, 6.5, Color::BLACK, None), + pn.log, + ), ); } - // reference lines: the tolerance itself, and the ends and middle of the station interval + // reference lines across the panel at any zoom: the tolerance itself, and the ends and + // middle of the station interval let reference_lines: &[f64] = match pn.key { "state_ratio" => &[1.0], "trpos_upper" | "trpos_lower" => &[0.0, 0.5, 1.0], _ => &[], }; - all.extend(reference_lines); - let (lo, hi) = match pn.key { - "trpos_upper" | "trpos_lower" => (-0.02, 1.02), - "rmsbl" => limits(&all, true, Some(1e-8)), - _ => limits(&all, pn.log, None), - }; for &y in reference_lines { - ax.plot([ALPHA_RANGE.0, ALPHA_RANGE.1], [y, y]) - .color(Color::BLACK) - .line_width(0.5) - .dash(Dash::Dotted); - } - if !nan_ticks.is_empty() { - // an axes-fraction tick in the matplotlib figure; here a marker at the panel's top - let top = near_top(lo, hi, pn.log); - ax.plot(&nan_ticks, vec![top; nan_ticks.len()]) - .display_name(NAN_TICK) - .color(ORANGE) - .dash(Dash::None) - .marker(Marker::Plus) - .marker_size(4.0); + ax.hline(y).color(Color::BLACK).line_width(0.5).dash(Dash::Dotted); } + any_nan |= !nan_ticks_at.is_empty(); + nan_ticks(&mut ax, &nan_ticks_at); ax.xlabel("$\\alpha$ / deg") .ylabel(pn.ylabel) .xlim(ALPHA_RANGE.0, ALPHA_RANGE.1) - .ylim(lo, hi) - .legend_in(key); + .legend_in(key_panel); + // fixed limits only where they help reading: the interval with its ends clear of the box, + // and RMSBL from the convergence threshold's decades up; every other panel autoscales + match pn.key { + "trpos_upper" | "trpos_lower" => { + ax.ylim(-0.02, 1.02); + } + "rmsbl" => { + let (lo, hi) = limits(&all, true, Some(1e-8)); + ax.ylim(lo, hi); + } + _ => {} + } if pn.log { ax.yscale(Scale::Log); } - ids.push((pn.key, ax.id(), lo, hi)); + ids.push((pn.key, ax.id())); } fig.link_all_x().expect("link α"); // the upper/lower pairs plot one quantity: one y range for both for pair in [["xtr_upper", "xtr_lower"], ["trpos_upper", "trpos_lower"]] { - let found: Vec<_> = ids.iter().filter(|(k, ..)| pair.contains(k)).collect(); - if let [a, b] = found.as_slice() { - let (lo, hi) = (a.2.min(b.2), a.3.max(b.3)); - fig.axes((ids.iter().position(|i| i.1 == a.1).unwrap() / 2) as u32, 0) - .ylim(lo, hi); - fig.link(Dim::Y, &[a.1, b.1]).expect("link the pair"); + let found: Vec = ids + .iter() + .filter(|(k, _)| pair.contains(k)) + .map(|(_, id)| *id) + .collect(); + if found.len() == 2 { + fig.link(Dim::Y, &found).expect("link the pair"); } } + let mut panel = fig.panel_span(rows, 0, 1, 2); + let mut legend = panel.legend(); + legend.boxed(false).columns(LegendEntryColumns::Count(1)); + for (_, id) in &key.items { + legend.item(*id); + } + if any_nan { + legend.custom_item(nan_tick_sample(), NAN_TICK); + } fig } /// Every recorded point of every case: where transition sits within its station interval, one -/// row per case (numbered, the key names the case), one panel per side. +/// row per case, named by the case and first at the top, one panel per side. pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figure { let n = runs.len().max(1); let panel_pt = (11.0 * n as f64 + 8.0).max(120.0); - let key_rows = (runs.len() + 4) as f64; - let legend_pt = 11.0 * key_rows + 10.0; + // about four key rows, in a row fitted to the key + let legend_pt = 11.0 * 4.0 + 10.0; let height_pt = 42.0 + panel_pt + legend_pt; let mut fig = Figure::new() .size_mm(WIDTH_MM, height_pt * PT_TO_MM) .tiles(2, 2) - .row_weights([1.0, legend_pt / panel_pt]) + .fit_row(1) .title("Position of transition in its station interval, every recorded point of every case") .parameter("study", study.slug) .parameter("figure", "transition-strip") @@ -475,23 +564,23 @@ pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figur "matched", runs.iter().map(|r| r["matched"].as_i64().unwrap_or(0)).sum::(), ) - .label("validation") - .label("xfoil-comparison"); + .tag("validation") + .tag("xfoil-comparison"); let mut tags: Vec<&str> = cases.iter().flat_map(|c| regime_labels(c)).collect(); tags.sort_unstable(); tags.dedup(); for t in tags { - fig = fig.label(t); + fig = fig.tag(t); } let key = { - let mut legend = fig.panel_legend_span(1, 0, 1, 2); - legend - .boxed(false) - .columns(LegendEntryColumns::Count(1)) - .align(LegendLocation::NorthWest); + let mut panel = fig.panel_span(1, 0, 1, 2); + panel.align(PanelLocation::NorthWest); + let mut legend = panel.legend(); + legend.boxed(false).columns(LegendEntryColumns::Count(1)); legend.id() }; - let (ylo, yhi) = (0.5, n as f64 + 0.5); + // a case with no transition on the aerofoil (an inviscid one) keeps its row: rows are categories + let names: Vec = runs.iter().map(|r| mono(r["name"].as_str().unwrap_or(""))).collect(); let mut ids = vec![]; for (col, label) in [(0u32, "upper side"), (1, "lower side")] { let mut ax = fig.axes(0, col); @@ -500,8 +589,7 @@ pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figur let mut ill = Marks::default(); let mut mismatch = Marks::default(); for (row, run) in runs.iter().enumerate() { - let y = (row + 1) as f64; - let mut case_marks = Marks::default(); + let y = row as f64; for p in run["points"].as_array().into_iter().flatten() { let t = &p["transition_position"][col as usize]; if t.is_null() || t["at_te"].as_bool().unwrap_or(false) { @@ -510,7 +598,6 @@ pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figur let Some(x) = t["fraction"].as_f64().filter(|x| x.is_finite()) else { continue; }; - case_marks.push(x, y); if p["followed"].as_bool().unwrap_or(false) { followed.push(x, y); } else if p["outcome"] == "mismatch" { @@ -521,30 +608,10 @@ pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figur informational.push(x, y); } } - // a row label in the matplotlib figure; here a transparent series named by the case, - // so the key and the data tips say which row is which (a case with no transition on - // the aerofoil, an inviscid one, keeps its row and its key entry with a NaN point) - if case_marks.x.is_empty() { - case_marks.push(f64::NAN, y); - } - { - ax.plot(&case_marks.x, &case_marks.y) - .display_name(Text::plain(format!( - "{}: {}", - row + 1, - run["name"].as_str().unwrap_or("") - ))) - .color(Color::rgba(0.0, 0.0, 0.0, 0.0)) - .dash(Dash::None) - .marker(Marker::Circle) - .marker_size(5.0); - } } + // the ends and middle of the interval, across every row at any zoom for x in [0.0, 0.5, 1.0] { - ax.plot([x, x], [ylo, yhi]) - .color(Color::BLACK) - .line_width(0.4) - .dash(Dash::Dotted); + ax.vline(x).color(Color::BLACK).line_width(0.4).dash(Dash::Dotted); } let blue = figures::with_alpha(BLUE, 0.6); let look = |name, shape, size, edge, face| Look { @@ -571,20 +638,26 @@ pub fn transition_strip(study: &Study, cases: &[&Case], runs: &[Value]) -> Figur draw( &mut ax, &ill, - &look(ILL, Marker::Diamond, 5.0, Color::BLACK, None), + &look(TRANSITION_ILL, Marker::Diamond, 5.0, Color::BLACK, None), false, ); draw( &mut ax, &mismatch, - &look(MISMATCH, Marker::Diamond, 5.0, grey, Some(grey)), + &look(TRANSITION_MISMATCH, Marker::Diamond, 5.0, grey, Some(grey)), false, ); - ax.xlabel(format!("position of transition in its station interval, {label}")) - .ylabel(if col == 0 { "case (number in the key)" } else { "" }) + ax.ycategories(names.iter().map(String::as_str)) + .title(label) + .xlabel("position in the interval") .xlim(-0.02, 1.02) - .ylim(ylo, yhi) + .ylim(-0.5, n as f64 - 0.5) .legend_in(key); + if col == 0 { + ax.ylabel("case"); + } else { + ax.ytick_labels_hidden(); + } ids.push(ax.id()); } fig.link_all_x().expect("link the interval position"); @@ -637,10 +710,8 @@ mod tests { } #[test] - fn nan_marker_sits_inside_the_panel() { - let y = near_top(1e-8, 1.0, true); - assert!(y < 1.0 && y > 1e-8); - let y = near_top(0.0, 1.0, false); - assert!(y < 1.0 && y > 0.98); + fn case_names_are_monospace_with_latex_specials_escaped() { + assert_eq!(mono("naca0012_n160"), "\\texttt{naca0012\\_n160}"); + assert_eq!(text("a $5"), "a \\$5"); } } diff --git a/studies/series-cases/src/report.rs b/studies/series-cases/src/report.rs index 18e0ce89..35171ec7 100644 --- a/studies/series-cases/src/report.rs +++ b/studies/series-cases/src/report.rs @@ -158,7 +158,7 @@ fn body_md(study: &Study, s: &Value, figure: bool) -> String { marks a point during which that code went through a non-finite value: a station Newton whose residual \ was NaN (the iterate fallback then carries the march past it — for the reference, any `NaN` it \ printed during the call; for yFoil, its own count of such stations) or a non-finite RMSBL, CL, CD or CM \ - in any iteration; where the plotted value is itself NaN and cannot be placed, an orange plus at the top \ + in any iteration; where the plotted value is itself NaN and cannot be placed, an orange tick at the top \ of the panel marks its α. A black outer diamond marks a point within which yFoil parts from the \ reference where XFOIL is **ill-conditioned** (one of XFOIL's own twins parts there too: a divergent \ comparison, `docs/development/conventions/terminology.md`; the one-step replay from XFOIL's state at that \ @@ -326,7 +326,7 @@ pub fn write_index_tex(study: &Study, run_dir: &Path, s: &Value) { w(&mut f, &format!("\\noindent {}", tex_escape(study.purpose_tex))); w(&mut f, "Polars are swept $0\\rightarrow+30^\\circ$ and after INIT $0\\rightarrow-30^\\circ$ by $1^\\circ$ with XFOIL's polar procedure (ASEQ, ITMAX+5 per point, the sequence halting after NSEQEX = 4 consecutive unconverged points); yFoil driven the same way and compared point by point with the equivalence comparison. Provenance in \\texttt{metadata.json}, every number in \\texttt{summary.json}."); for r in s["polars"].as_array().unwrap() { - w(&mut f, &format!("\\begin{{figure}}[h]\\centering\\includegraphics{{polars-{}.pdf}}\\caption{{{} (\\texttt{{{}}}): $C_L$ and $C_D$ against $\\alpha$ (top), the last iteration's RMSBL and the iterations taken (second row), the transition location on each side (third row; an open triangle where the final IST or ITRAN differs between the codes), the position of transition within its station interval on each side (fourth row) and, where the fixture keeps the reference's per-call state, yFoil's final state against the reference's, worst array, as a ratio to its tolerance and as $|\\Delta|$ against the twin's 1-ULP floor (fifth row). XFOIL red (dashed, crosses), yFoil blue (solid, circles filled where every iteration of the point agrees with the reference's); an orange outer square marks a point in which that code went through a non-finite value, an orange plus at the top of the panel a plotted value that is itself NaN; a black outer diamond a point within which yFoil parts from the reference where XFOIL is ill-conditioned (its own 1-ULP twins part there too), a filled grey diamond one where XFOIL is well-conditioned (a mismatch). Where the red line stops short of the blue one the reference's sequence halted or hung there.}}\\end{{figure}}", r["name"].as_str().unwrap(), tex_escape(r["section"].as_str().unwrap()), tex_escape(r["name"].as_str().unwrap()))); + w(&mut f, &format!("\\begin{{figure}}[h]\\centering\\includegraphics{{polars-{}.pdf}}\\caption{{{} (\\texttt{{{}}}): $C_L$ and $C_D$ against $\\alpha$ (top), the last iteration's RMSBL and the iterations taken (second row), the transition location on each side (third row; an open triangle where the final IST or ITRAN differs between the codes), the position of transition within its station interval on each side (fourth row) and, where the fixture keeps the reference's per-call state, yFoil's final state against the reference's, worst array, as a ratio to its tolerance and as $|\\Delta|$ against the twin's 1-ULP floor (fifth row). XFOIL red (dashed, crosses), yFoil blue (solid, circles filled where every iteration of the point agrees with the reference's); an orange outer square marks a point in which that code went through a non-finite value, an orange tick at the top of the panel a plotted value that is itself NaN; a black outer diamond a point within which yFoil parts from the reference where XFOIL is ill-conditioned (its own 1-ULP twins part there too), a filled grey diamond one where XFOIL is well-conditioned (a mismatch). Where the red line stops short of the blue one the reference's sequence halted or hung there.}}\\end{{figure}}", r["name"].as_str().unwrap(), tex_escape(r["section"].as_str().unwrap()), tex_escape(r["name"].as_str().unwrap()))); } w(&mut f, "\\begin{figure}[h]\\centering\\includegraphics{transition-position.pdf}\\caption{Where transition sits within its station interval (0 at station ITRAN$-$1, 1 at station ITRAN) at the end of every recorded point of every case, per side, from yFoil's state; diamonds where the run parts (black: the reference is unstable against its 1-ULP twins there; grey: it is stable).}\\end{figure}"); w(&mut f, "\\begin{table}[h]\\centering\\small"); diff --git a/studies/study-support/publish.rs b/studies/study-support/publish.rs index 9af6a5f7..80bb6f84 100644 --- a/studies/study-support/publish.rs +++ b/studies/study-support/publish.rs @@ -13,7 +13,7 @@ //! publish/ //! tables/run.md which run this is (included at the top of the study's index page) //! tables/*.md Markdown table fragments -//! figures/* ironlab `.fig` (and the branch-coverage study's SVGs) +//! figures/* ironlab `.fig` //! ``` use std::fs; diff --git a/studies/xfoil-instrumentation-check/Cargo.toml b/studies/xfoil-instrumentation-check/Cargo.toml index 6fb76135..24036e3c 100644 --- a/studies/xfoil-instrumentation-check/Cargo.toml +++ b/studies/xfoil-instrumentation-check/Cargo.toml @@ -13,4 +13,4 @@ workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } yfoil = { path = "../..", features = ["plotting"] } -ironlab = "=0.12.0" +ironlab = "=0.16.0" diff --git a/studies/xfoil-instrumentation-check/src/figure.rs b/studies/xfoil-instrumentation-check/src/figure.rs index 9cbbd464..57ab8c79 100644 --- a/studies/xfoil-instrumentation-check/src/figure.rs +++ b/studies/xfoil-instrumentation-check/src/figure.rs @@ -1,6 +1,7 @@ //! The instrumentation-check figure, built from `summary.json` alone as an ironlab figure -//! (`docs/development/conventions/figures.md`): a legend panel across the top with one entry per build (its -//! converged count and where its plotted series ends), then a 2×2 grid against α — CL, CD and CM +//! (`docs/development/conventions/figures.md`): a legend panel across the top, in a row fitted to it, with +//! one entry per build (its converged count and where its plotted series ends), then a 2×2 grid +//! against α (tick labels on the bottom row only) — CL, CD and CM //! (pristine red with crosses, instrumented blue with open circles) and the upper-surface //! trailing-edge boundary layer on a logarithmic axis (one colour per variable, pristine solid, //! instrumented dashed). α is linked across all four panels; no two panels plot the same @@ -72,9 +73,9 @@ pub fn build(s: &Summary) -> Figure { let fig = Figure::new() .size_mm(figures::WIDTH_MM, HEIGHT_MM) .tiles(3, 2) - .row_weights([0.5, 2.0, 2.0]) - .label("testing") - .label("xfoil-comparison") + .fit_row(0) + .tag("testing") + .tag("xfoil-comparison") .parameter("study", "xfoil-instrumentation-check") .parameter("figure", "instrumentation-check") .parameter("foil", format!("naca{}", s.foil)) @@ -85,7 +86,8 @@ pub fn build(s: &Summary) -> Figure { .parameter("dumps_identical", s.comparison.dumps_identical as i64); let mut fig = figures::flow_condition_parameters(fig, &conditions(s)); let key = fig - .panel_legend_span(0, 0, 1, 2) + .panel_span(0, 0, 1, 2) + .legend() .boxed(false) .columns(LegendEntryColumns::Count(1)) .id(); @@ -102,7 +104,7 @@ pub fn build(s: &Summary) -> Figure { let end = b.series_end_alpha_deg.map_or("—".to_string(), |a| format!("{a:.1}°")); let values: Vec = b.series.iter().map(get).collect(); ax.plot(alphas(&b.series), values) - .display_name(format!( + .label(format!( "{}: {} converged points, series to {end}", b.label, b.points.len() @@ -112,10 +114,13 @@ pub fn build(s: &Summary) -> Figure { .marker(marker) .marker_size(MARKER_PT); } - ax.xlabel(r"$\alpha$ (°)") - .ylabel(*label) - .xlim(x_lo, x_hi) - .legend_in(key); + if i == 2 { + ax.xlabel(r"$\alpha$ (°)"); + } + ax.ylabel(*label).xlim(x_lo, x_hi).legend_in(key); + if i < 2 { + ax.xtick_labels_hidden(); + } if let Some(e) = s.extents.get(*metric) { let (lo, hi) = padded(e); ax.ylim(lo, hi); @@ -149,7 +154,7 @@ pub fn build(s: &Summary) -> Figure { .marker_size(MARKER_PT); // one entry per variable, drawn from the pristine (solid) line if b.build == Build::Pristine { - line.display_name(*label); + line.label(*label); } } } @@ -206,19 +211,12 @@ mod tests { } #[test] - fn figure_validates_and_exports_without_legend_warnings() { + fn figure_validates_and_lays_out_without_warnings() { let fig = build(&summary()); let report = fig.validate(); assert!(report.errors.is_empty(), "{:?}", report.errors); - // temporary files go in the repository's .tmp/, never /tmp - let dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../.tmp") - .join(format!("xfoil-instrumentation-check-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let export = fig.export_pdf(dir.join("check.pdf")).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - assert!(export.scene.is_empty(), "{:?}", export.scene); + assert!(fig.layout_warnings().is_empty(), "{:?}", fig.layout_warnings()); assert_eq!(fig.parameters().get("re"), Some(&1e6.into())); - assert_eq!(fig.labels(), ["testing", "xfoil-comparison"]); + assert_eq!(fig.tags(), ["testing", "xfoil-comparison"]); } } diff --git a/tests/application/cli_plot.rs b/tests/application/cli_plot.rs index 518b8eeb..a6b90d37 100644 --- a/tests/application/cli_plot.rs +++ b/tests/application/cli_plot.rs @@ -1,6 +1,6 @@ //! (c) yFoil functionality: `yfoil plot foil | analysis | polar` end to end through the binary: //! geometry, analysis and polar inputs, the ironlab figures they write (loaded back and checked -//! for their axes, artists, key, links, parameters and labels), and the input-dispatch errors. +//! for their axes, artists, key, links, parameters and tags), and the input-dispatch errors. //! Needs the `plotting` feature. //! //! Fixtures: none (every input is generated by the binary). @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use ironlab::ir::{Artist, Dimension, Line, Parameter}; +use ironlab::ir::{Artist, Dimension, LegendEntries, LegendItemContent, PanelContent, Parameter, Series}; use ironlab::Figure; use yfoil::output::AnalysisOutput; @@ -120,13 +120,13 @@ impl Case { } } -/// The lines of axes `k`, in drawing order -fn lines(fig: &Figure, k: usize) -> Vec<&Line> { +/// The series of axes `k`, in drawing order +fn lines(fig: &Figure, k: usize) -> Vec<&Series> { fig.ir().axes[k] .artists .iter() .filter_map(|a| match a { - Artist::Line(l) => Some(l), + Artist::Series(l) => Some(l), _ => None, }) .collect() @@ -136,13 +136,37 @@ fn lines(fig: &Figure, k: usize) -> Vec<&Line> { fn names(fig: &Figure, k: usize) -> Vec { lines(fig, k) .iter() - .filter_map(|l| l.display_name.as_ref().map(|t| t.content.clone())) + .filter_map(|l| l.label.as_ref().map(|t| t.content.clone())) .collect() } -/// A line's x and y values -fn data(fig: &Figure, line: &Line) -> (Vec, Vec) { - let get = |id| fig.ir().data[&id].as_f64().unwrap().to_vec(); +/// The labels of the items a panel legend lists for no artist (the foil figure's marker key), +/// over every legend panel +fn custom_items(fig: &Figure) -> Vec { + fig.ir() + .panels + .iter() + .filter_map(|p| match &p.content { + PanelContent::Legend(l) => Some(l), + _ => None, + }) + .flat_map(|l| match &l.entries { + LegendEntries::Explicit(items) => items.iter().collect(), + _ => Vec::new(), + }) + .filter_map(|item| match &item.content { + LegendItemContent::Custom { label, .. } => Some(label.content.clone()), + _ => None, + }) + .collect() +} + +/// A series' x and y values +fn data(fig: &Figure, line: &Series) -> (Vec, Vec) { + let get = |id| { + let a = fig.ir().data.iter().find(|d| d.id == id).unwrap(); + a.array.as_f64().unwrap().to_vec() + }; (get(line.x), get(line.y)) } @@ -182,7 +206,7 @@ fn geometry_inputs_overlay_panels_only() { ok_in(Some(c.dir.path()), &["plot", "foil", c.p("n0012.json")]); let fig = c.figure("n0012_foil.fig"); assert_eq!(fig.ir().axes.len(), 1); - assert!(fig.ir().panel_legends.is_empty(), "no key for a single geometry"); + assert!(fig.ir().panels.is_empty(), "no key for a single geometry"); // outline + one line of 60 notches (the default for geometry input), NaN between notches let ls = lines(&fig, 0); assert_eq!(ls.len(), 2); @@ -214,7 +238,7 @@ fn geometry_inputs_overlay_panels_only() { assert_eq!(dots.len(), 2); assert!(dots.iter().all(|l| l.line.dash == ironlab::Dash::None)); assert_eq!(names(&fig, 0), ["n0012", "n0012_40"], "the key names each geometry"); - assert_eq!(fig.ir().panel_legends.len(), 1); + assert_eq!(fig.ir().panels.len(), 1); assert!(param(&fig, "n_nodes").is_none(), "the panel counts differ"); assert_eq!(param(&fig, "points"), Some(&Parameter::from(2_i64))); @@ -246,7 +270,8 @@ fn analysis_inputs_draw_quantities_wake_and_markers() { ]); let fig = c.figure("one.fig"); assert_eq!(fig.ir().axes.len(), 1); - assert_eq!(fig.ir().panel_legends.len(), 1, "one key, in a panel below the axes"); + assert_eq!(fig.ir().panels.len(), 2, "a curve key and a marker key, below the axes"); + assert_eq!(fig.ir().axes[0].aspect, ironlab::Aspect::Equal); let ls = lines(&fig, 0); // surface, wake line, aerofoil and wake notches, then per quantity: upper, lower, wake // upper, wake lower; then one line per marker @@ -261,14 +286,24 @@ fn analysis_inputs_draw_quantities_wake_and_markers() { let key = names(&fig, 0); assert!(key[0].starts_with("$\\delta^*$, scaled ×"), "{key:?}"); assert!(key[1].starts_with("$\\theta$, scaled ×"), "{key:?}"); - // the stagnation circle, and a key entry per kind and side drawn (this case has a laminar - // separation bubble) - for name in ["stagnation", "transition (upper surface)"] { - assert!(key.iter().any(|k| k == name), "{name} in {key:?}"); + assert_eq!( + key.len(), + 2, + "the markers are explained by the marker key, not named: {key:?}" + ); + // the stagnation circle, and a marker-key entry per kind drawn then per side (this case has + // a laminar separation bubble) + let marks = custom_items(&fig); + for name in [ + "stagnation", + "transition", + "separation", + "reattachment", + "upper surface", + "lower surface", + ] { + assert!(marks.iter().any(|k| k == name), "{name} in {marks:?}"); } - assert!(key.iter().any(|k| k.ends_with("transition (lower surface)")), "{key:?}"); - assert!(key.iter().any(|k| k.starts_with("separation (")), "{key:?}"); - assert!(key.iter().any(|k| k.starts_with("reattachment (")), "{key:?}"); let circles = ls.iter().filter(|l| l.marker.shape == ironlab::Marker::Circle).count(); assert_eq!(circles, 1); // one operating point: its inputs and outputs @@ -287,7 +322,7 @@ fn analysis_inputs_draw_quantities_wake_and_markers() { assert!(number(&fig, name).is_finite(), "{name}"); } assert!( - fig.labels().is_empty(), + fig.tags().is_empty(), "incompressible, free transition, Re 1e6: no regime tags" ); @@ -430,28 +465,30 @@ fn polar_distributions_select_design_points() { } #[test] -fn analysis_figure_draws_minus_cp_and_ue_over_a_linked_x() { +fn analysis_figure_draws_cp_reversed_and_ue_over_a_linked_x() { let c = Case::new(); ok(&["plot", "analysis", c.p("a4.json")]); let fig = c.figure("a4.fig"); let ir = fig.ir(); - assert_eq!(ir.axes.len(), 3, "−Cp, Ue/U∞ and the aerofoil strip"); - assert!(ir.panel_legends.is_empty(), "the key belongs to the Cp panel"); + assert_eq!(ir.axes.len(), 3, "Cp, Ue/U∞ and the aerofoil strip"); + assert!(ir.panels.is_empty(), "the key belongs to the Cp panel"); + assert!(ir.axes[0].y.reverse, "suction up"); + assert!(!ir.axes[1].y.reverse); assert_eq!(links(&fig, Dimension::X), vec![vec![0, 1, 2]]); assert!(links(&fig, Dimension::Y).is_empty()); let label = |k: usize| ir.axes[k].y.label.as_ref().map(|t| t.content.clone()); - assert_eq!(label(0).as_deref(), Some("$-C_p$")); + assert_eq!(label(0).as_deref(), Some("$C_p$")); assert_eq!(label(1).as_deref(), Some("$U_e/U_\\infty$")); assert_eq!(names(&fig, 0), ["Upper surface", "Lower surface"]); assert!(names(&fig, 1).is_empty()); assert_eq!(lines(&fig, 2).len(), 1); - // the data are the record's, Cp negated + // the data are the record's let analysis: AnalysisOutput = serde_json::from_str(&std::fs::read_to_string(c.path("a4.json")).unwrap()).unwrap(); let (xu, cpu, qu) = analysis.upper_surface().unwrap(); let (x, y) = data(&fig, lines(&fig, 0)[0]); assert_eq!(x, xu); - assert!(y.iter().zip(&cpu).all(|(a, b)| *a == -*b)); + assert_eq!(y, cpu); assert_eq!(data(&fig, lines(&fig, 1)[0]).1, qu); assert_eq!(param(&fig, "figure"), Some(&Parameter::from("analysis"))); @@ -463,7 +500,7 @@ fn analysis_figure_draws_minus_cp_and_ue_over_a_linked_x() { ok(&["plot", "analysis", c.p("a4m3.json"), "-o", c.p("m3.fig")]); let fig = c.figure("m3.fig"); assert_eq!(number(&fig, "mach"), 0.3); - assert_eq!(fig.labels(), ["compressible"]); + assert_eq!(fig.tags(), ["compressible"]); fails( &["plot", "analysis", c.p("a4.json"), "-o", c.p("a4.svg")], @@ -485,7 +522,7 @@ fn polar_figure_is_a_linked_sheet_with_one_key() { let fig = c.figure("polar_comparison.fig"); let ir = fig.ir(); assert_eq!(ir.axes.len(), 4); - assert_eq!(ir.panel_legends.len(), 1); + assert_eq!(ir.panels.len(), 1); // α across the CL–α, CM–α and CD–α panels; CL between CL–α and CL–CD assert_eq!(links(&fig, Dimension::X), vec![vec![0, 2, 3]]); assert_eq!(links(&fig, Dimension::Y), vec![vec![0, 1]]); diff --git a/xtask/src/publish.rs b/xtask/src/publish.rs index a2f8cb4d..3af5e4f8 100644 --- a/xtask/src/publish.rs +++ b/xtask/src/publish.rs @@ -6,7 +6,7 @@ //! this command replaces `docs/validation//{tables,figures}/` with the chosen run's bundle. //! It never writes a page: the pages of `docs/validation//` are written by hand, and only //! include the tables (`--8<-- "validation//tables/.md"`) and embed the figures -//! (``, `![...](figures/.svg)`). +//! (``). //! //! Before copying, every reference the study's pages make into its `tables/` or `figures/` is //! checked against the bundle, and the publish is refused if one is missing (Zensical would fail on