From 84c15ad24f06cd292cb3858edf95889bc9d44765 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:15:41 +0200 Subject: [PATCH 1/4] Write and read polars through the node-table I/O, so they follow table_format read_node_table / write_node_rows take named value columns and an optional delta column, so a POLAR_VECTORS or POLAR_MATRICES table is one of their tables: the polar writers and load_polar_data go through them, CSV or Arrow by suffix. csv_fields moves beside them as the one CSV row formatter. generate_airfoils names the polar with the table_format suffix, and migrate_node_tables converts a cached dataset's polar too, now also on surfplan_to_aero_yaml's reuse path. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + docs/src/airfoil_pipeline.md | 15 +- docs/src/private_functions.md | 5 +- src/VortexStepMethod.jl | 1 + src/airfoil_aero/AirfoilAero.jl | 2 +- src/airfoil_aero/airfoil_io.jl | 23 +-- src/airfoil_aero/airfoil_solvers/common.jl | 40 +++--- src/airfoil_aero/geometry_gen.jl | 17 +-- src/obj_adapter/obj_to_yaml.jl | 26 ++-- src/section_aero.jl | 133 +++++++++++------ src/surfplan_adapter/SurfplanAdapter.jl | 13 +- src/yaml_geometry.jl | 157 +++++---------------- test/airfoil_aero/test_airfoil_aero.jl | 49 +++++++ test/obj_adapter/test_obj_adapter.jl | 13 +- test/surfplan/test_surfplan.jl | 13 ++ 15 files changed, 267 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073286e2..21750bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and at flap deflection `delta`, in one figure instead of one coefficient per call. - `linearize` takes a `BodyAerodynamics` with more than one wing; `theta_idxs` and `delta_idxs` then run over the unrefined sections of all wings in order. +- `table_format=:arrow` writes the polars as `.arrow` too, not only the per-node `Cp`/`cf` + tables: `write_polar_csv` and `write_polar_matrix_csv` write Arrow for an `.arrow` path + and `load_polar_data` reads it. `obj_to_yaml` and now also `surfplan_to_aero_yaml` + convert a reused dataset's polars along with its node tables. CSV polars load as before. ### Changed diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index 7d8b7a58..9b8ae6c0 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -99,19 +99,20 @@ For each unique airfoil id `j`, `obj_to_yaml` writes into `output_dir`: point), e.g. `_d5.dat` for 5°, `_dm3.dat` for −3°, `_d2p5.dat` for 2.5° - `polars/{j}.csv` — the generated polar (`POLAR_VECTORS` or `POLAR_MATRICES`) - `pressure/{j}_cp.csv`, `pressure/{j}_cf.csv` — surface pressure and skin friction per - contour node over the `alpha × delta` grid, what pressure integration reads. - `table_format=:arrow` writes these two as `.arrow` instead: binary, roughly an order of - magnitude faster to load and a third of the size. `read_section_aero` detects the format - from the suffix, so both are loadable and a directory can be migrated between them with - [`convert_node_table`](@ref VortexStepMethod.convert_node_table) — which `obj_to_yaml` - does itself when `table_format` differs from what the directory holds, without re-running - the solver + contour node over the `alpha × delta` grid, what pressure integration reads - `geometry.yaml` — `wing_sections` (leading/trailing-edge points) plus `wing_airfoils` (each section's `type` and the table paths above). `geometry_path` writes the YAML somewhere else, in which case the emitted table references carry the path from the YAML's directory to `output_dir` — the geometry loader resolves them against the YAML's own directory +`table_format=:arrow` writes the polar and the two node tables as `.arrow` instead: binary, roughly an +order of magnitude faster to load and a third of the size. The loaders detect the format +from the suffix, so both are loadable and a directory can be migrated between them with +[`convert_node_table`](@ref VortexStepMethod.convert_node_table) — which `obj_to_yaml` and +`surfplan_to_aero_yaml` do themselves when `table_format` differs from what the directory +holds, without re-running the solver. + A tip that tapers to a point has no airfoil to slice, so the outermost stations stop at the last slice that still has a chord; `wingtip_distance` moves them a further arc length inboard when the slices just short of the tip are still too thin to analyse. A diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index 6cae4b09..d07934c8 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -123,11 +123,15 @@ same_knots polar_knots window_alpha assemble_polar_matrix +load_polar_data load_matrix_polar_data read_aero_matrix read_dat_coordinates read_node_table +read_arrow_columns +read_csv_columns write_node_rows +csv_fields convert_node_table delta_suffix interpolate_matrix_nans! @@ -214,7 +218,6 @@ create_2d_polars lei_poly_coeffs resolve_airfoil write_dat -csv_fields write_polar_csv write_polar_matrix_csv write_aero_matrix diff --git a/src/VortexStepMethod.jl b/src/VortexStepMethod.jl index f2073e9e..6f473f1c 100644 --- a/src/VortexStepMethod.jl +++ b/src/VortexStepMethod.jl @@ -15,6 +15,7 @@ using SciMLBase import NonlinearSolve: solve, solve! using Interpolations using Parameters +using Printf: @sprintf using Serialization using Timers using PreallocationTools diff --git a/src/airfoil_aero/AirfoilAero.jl b/src/airfoil_aero/AirfoilAero.jl index 2e7eea0f..01bcd0b1 100644 --- a/src/airfoil_aero/AirfoilAero.jl +++ b/src/airfoil_aero/AirfoilAero.jl @@ -8,7 +8,7 @@ using NPZ using Xfoil using Printf: @sprintf using ..VortexStepMethod: SectionAero, interpolate_matrix_nans!, delta_suffix, - write_node_rows, section_surface, set_polar!, + write_node_rows, csv_fields, section_surface, set_polar!, read_dat_coordinates, KulfanParameters, calculate_cl, calculate_cd, calculate_cm diff --git a/src/airfoil_aero/airfoil_io.jl b/src/airfoil_aero/airfoil_io.jl index 24cd2bc4..ccf3e85a 100644 --- a/src/airfoil_aero/airfoil_io.jl +++ b/src/airfoil_aero/airfoil_io.jl @@ -13,28 +13,17 @@ function write_dat(filepath::String, name::String, x::Vector, y::Vector) return filepath end -""" - csv_fields(values) -> String - -Join `values` into one comma-separated CSV line at 16 significant digits. -""" -csv_fields(values) = join((@sprintf("%.16g", v) for v in values), ",") - """ write_polar_csv(filepath, result::NeuralFoilResult) -Write a NeuralFoil result to a `POLAR_VECTORS` CSV (`alpha, Cd, Cs, Cl, Cm`). +Write a NeuralFoil result to a `POLAR_VECTORS` table (`alpha, Cd, Cs, Cl, Cm`), CSV or +Arrow as the suffix of `filepath` says (see [`write_node_rows`](@ref +VortexStepMethod.write_node_rows)). """ function write_polar_csv(filepath::String, result::NeuralFoilResult) - n = length(result.alpha) - open(filepath, "w") do io - println(io, "alpha,Cd,Cs,Cl,Cm") - for i in 1:n - row = (result.alpha[i], result.CD[i], 0.0, result.CL[i], result.CM[i]) - println(io, csv_fields(row)) - end - end - return filepath + values = [result.CD zero(result.CD) result.CL result.CM] + return write_node_rows(filepath, deg2rad.(result.alpha), nothing, values; + columns=["Cd", "Cs", "Cl", "Cm"]) end """ diff --git a/src/airfoil_aero/airfoil_solvers/common.jl b/src/airfoil_aero/airfoil_solvers/common.jl index 4276a4ef..f6d96de9 100644 --- a/src/airfoil_aero/airfoil_solvers/common.jl +++ b/src/airfoil_aero/airfoil_solvers/common.jl @@ -35,39 +35,33 @@ end write_polar_csv(filepath, sols::Vector{SectionSolution}) Write a solver sweep (from any [`AbstractAirfoilSolver`](@ref)) to a `POLAR_VECTORS` -CSV (`alpha, Cd, Cs, Cl, Cm`; alpha in degrees). Non-converged angles (`NaN`) are -skipped, so this works for both NeuralFoil and XFoil sweeps. +table (`alpha, Cd, Cs, Cl, Cm`; alpha in degrees), CSV or Arrow as the suffix of +`filepath` says. Non-converged angles (`NaN`) are skipped, so this works for both +NeuralFoil and XFoil sweeps. """ function write_polar_csv(filepath::String, sols::Vector{SectionSolution}) - open(filepath, "w") do io - println(io, "alpha,Cd,Cs,Cl,Cm") - for s in sols - isnan(s.cl) && continue - row = (rad2deg(s.alpha), s.cd, 0.0, s.cl, s.cm) - println(io, csv_fields(row)) - end - end - return filepath + converged = filter(sol -> !isnan(sol.cl), sols) + alpha, cd, cl, cm = ([getfield(sol, name) for sol in converged] + for name in (:alpha, :cd, :cl, :cm)) + return write_node_rows(filepath, alpha, nothing, [cd zero(cd) cl cm]; + columns=["Cd", "Cs", "Cl", "Cm"]) end """ write_polar_matrix_csv(filepath, alpha_range, delta_range, cl, cd, cm) -Write an `(alpha × delta)` sweep to a long-format `POLAR_MATRICES` CSV with columns -`alpha, delta, Cl, Cd, Cm` (both angles in degrees), one row per grid point. The -`delta` column is what marks the file as a matrix polar to the loader. `alpha_range` -and `delta_range` are in radians; `cl`/`cd`/`cm` are `length(alpha) × length(delta)`. +Write an `(alpha × delta)` sweep to a long-format `POLAR_MATRICES` table with columns +`alpha, delta, Cl, Cd, Cm` (both angles in degrees), one row per grid point, CSV or +Arrow as the suffix of `filepath` says. The `delta` column is what marks the file as a +matrix polar to the loader. `alpha_range` and `delta_range` are in radians; +`cl`/`cd`/`cm` are `length(alpha) × length(delta)`. """ function write_polar_matrix_csv(filepath::String, alpha_range, delta_range, cl::AbstractMatrix, cd::AbstractMatrix, cm::AbstractMatrix) - open(filepath, "w") do io - println(io, "alpha,delta,Cl,Cd,Cm") - for (j, d) in enumerate(delta_range), (i, a) in enumerate(alpha_range) - row = (rad2deg(a), rad2deg(d), cl[i, j], cd[i, j], cm[i, j]) - println(io, csv_fields(row)) - end - end - return filepath + alpha = repeat(collect(alpha_range), length(delta_range)) + delta = repeat(collect(delta_range); inner=length(alpha_range)) + return write_node_rows(filepath, alpha, delta, [vec(cl) vec(cd) vec(cm)]; + columns=["Cl", "Cd", "Cm"]) end """ diff --git a/src/airfoil_aero/geometry_gen.jl b/src/airfoil_aero/geometry_gen.jl index 6244664d..9881df82 100644 --- a/src/airfoil_aero/geometry_gen.jl +++ b/src/airfoil_aero/geometry_gen.jl @@ -16,10 +16,11 @@ the wrapped airfoil the solver analyses; `x_raw`/`y_raw` the raw points it enclo Writes into `output_dir` (indexed by `id`), one directory per file kind: `airfoils/{id}.dat` (wrapped shape), `airfoils/{id}_{delta_suffix(δ)}.dat` (per deflection), `airfoils/{id}_raw.dat` (raw points); `pressure/{id}_cp.{table_format}` / -`_cf.{table_format}` (per-node surface pressure and skin friction, `:csv` or the far -faster-loading `:arrow`); and `polars/{id}.csv` (`POLAR_VECTORS`, or a `POLAR_MATRICES` -grid when `delta_range` is set). `aero_solver` selects the backend -([`NeuralFoilSolver`](@ref) default, [`XFoilSolver`](@ref) opt-in). +`_cf.{table_format}` (per-node surface pressure and skin friction); and +`polars/{id}.{table_format}` (`POLAR_VECTORS`, or a `POLAR_MATRICES` grid when +`delta_range` is set). `table_format` is `:csv` or the far faster-loading `:arrow`. +`aero_solver` selects the backend ([`NeuralFoilSolver`](@ref) default, +[`XFoilSolver`](@ref) opt-in). With `reuse_valid_airfoils=true` an airfoil the solver cannot converge is skipped (the caller maps its sections to the nearest solved id); otherwise it errors. @@ -37,7 +38,7 @@ function generate_airfoils(airfoils, output_dir::String; for af in airfoils j = af.id raw_rel = joinpath("airfoils", "$(j)_raw.dat") - csv_rel = joinpath("polars", "$j.csv") + polar_rel = joinpath("polars", "$j.$table_format") try alphas = deg2rad.(collect(Float64, alpha_range)) deltas = isnothing(delta_range) ? [0.0] : @@ -49,11 +50,11 @@ function generate_airfoils(airfoils, output_dir::String; clvals = collect(sol.cl for sol in sols[1]) all(isnan, clvals) && error("solver produced no converged points") if isnothing(delta_range) - write_polar_csv(joinpath(output_dir, csv_rel), sols[1]) + write_polar_csv(joinpath(output_dir, polar_rel), sols[1]) else coeff(f) = [f(sols[jd][ia]) for ia in eachindex(alphas), jd in eachindex(deltas)] - write_polar_matrix_csv(joinpath(output_dir, csv_rel), alphas, deltas, + write_polar_matrix_csv(joinpath(output_dir, polar_rel), alphas, deltas, coeff(s -> s.cl), coeff(s -> s.cd), coeff(s -> s.cm)) end paths = write_section_aero(joinpath(output_dir, "airfoils", "$j"), aero; @@ -63,7 +64,7 @@ function generate_airfoils(airfoils, output_dir::String; af.x_raw, af.y_raw) push!(airfoil_rows, Any[j, "polar_vectors", Dict("dat_file" => dat_rel, "raw_dat_file" => raw_rel, - "csv_file_path" => csv_rel, "cp_file" => cp_rel, + "csv_file_path" => polar_rel, "cp_file" => cp_rel, "cf_file" => cf_rel)]) push!(ok, j) finite_cl = [v for v in clvals if !isnan(v)] diff --git a/src/obj_adapter/obj_to_yaml.jl b/src/obj_adapter/obj_to_yaml.jl index 10dc038f..8e68d80d 100644 --- a/src/obj_adapter/obj_to_yaml.jl +++ b/src/obj_adapter/obj_to_yaml.jl @@ -58,8 +58,9 @@ to use XFoil instead. Each section's polar is written as `POLAR_VECTORS`. `crease_frac` is the chordwise hinge location (0–1) about which each `delta_range` trailing-edge deflection pivots. -With `force=false` (default) an existing geometry YAML is reused; `force=true` -regenerates it (e.g. after changing `delta_range` or the mesh). +With `force=false` (default) an existing geometry YAML is reused, its tables converted +to `table_format` where they are in another ([`migrate_node_tables`](@ref)); +`force=true` regenerates it (e.g. after changing `delta_range` or the mesh). `geometry_path` names the YAML itself, `output_dir/geometry.yaml` by default. Point it elsewhere to keep the YAML out of the table directory — the emitted table references @@ -83,16 +84,16 @@ reused. With `reuse_valid_airfoils=false` the degenerate fit is written as is. # Output Writes into `output_dir`, indexed by source-airfoil id `j` (degenerate sections -share a neighbour's id): +share a neighbour's id), each table as `table_format` `:csv` (default, readable) or +`:arrow` (binary, an order of magnitude faster to load): - `airfoils/{j}.dat` — shrink-wrapped airfoil coordinates (matches the polar) - `airfoils/{j}_raw.dat` — raw sliced section points (the wrap encloses these) - `airfoils/{j}_d{tag}.dat` — each trailing-edge-deflected shape (with a `delta_range`); `{tag}` is the deflection in degrees with `m` for minus and `p` for the decimal point (e.g. `_dm3.dat`, `_d2p5.dat`) -- `polars/{j}.csv` — NeuralFoil polar (alpha, Cd, Cs, Cl, Cm) +- `polars/{j}.{table_format}` — NeuralFoil polar (alpha, Cd, Cs, Cl, Cm) - `pressure/{j}_cp.{table_format}`, `_cf.{table_format}` — per-node surface pressure and - skin friction; `table_format` is `:csv` (default, readable) or `:arrow` (binary, an - order of magnitude faster to load) + skin friction - `geometry.yaml` — `wing_sections` + `wing_airfoils` referencing the above # Returns @@ -103,11 +104,10 @@ function obj_to_yaml end """ migrate_node_tables(yaml_path, output_dir, table_format; verbose=true) -Rewrite a generated dataset's per-node `Cp`/`cf` tables in `table_format` and point -`geometry.yaml` at them, when they are not in that format already. This is what lets -an existing directory change format without re-running the airfoil solver that -produced it — the polars are the slow part and they are untouched. The source tables -are left in place. +Rewrite a generated dataset's per-node `Cp`/`cf` tables and polars in `table_format` +and point `geometry.yaml` at them, when they are not in that format already. This is +what lets an existing directory change format without re-running the airfoil solver +that produced it. The source tables are left in place. `output_dir` is what the YAML's relative table references resolve against, which is its own directory — the rule the geometry loader follows. @@ -126,7 +126,7 @@ function migrate_node_tables(yaml_path::String, output_dir::String, for row in airfoils["data"] info = row[info_col] info isa AbstractDict || continue - for key in ("cp_file", "cf_file") + for key in ("cp_file", "cf_file", "csv_file_path") haskey(info, key) || continue relative = String(info[key]) endswith(relative, ".$table_format") && continue @@ -146,7 +146,7 @@ function migrate_node_tables(yaml_path::String, output_dir::String, write_geometry_yaml(yaml_path, data["wing_sections"]["data"], [Any[row[id_col], row[type_col], row[info_col]] for row in airfoils["data"]]) - verbose && @info "Converted $converted node tables to :$table_format" yaml_path + verbose && @info "Converted $converted tables to :$table_format" yaml_path return yaml_path end diff --git a/src/section_aero.jl b/src/section_aero.jl index af52413a..2b856f0f 100644 --- a/src/section_aero.jl +++ b/src/section_aero.jl @@ -141,61 +141,106 @@ function read_dat_coordinates(path::AbstractString) end """ - read_node_table(path) -> (alpha, delta, values) + csv_fields(values) -> String -Read a per-node aero table into radian `alpha`/`delta` vectors (one entry per row) and a -`nrow × n_node` value matrix. The file suffix picks the format: `.arrow` (columns -`alpha`, `delta` in degrees and a per-row list column `values`), anything else CSV -(header `alpha, delta, n0, n1, …`, angles in degrees). +Join `values` into one comma-separated CSV line at 16 significant digits. +""" +csv_fields(values) = join((@sprintf("%.16g", v) for v in values), ",") + +""" + read_node_table(path) -> (alpha, delta, values, columns) + +Read an aero table into a radian `alpha` vector (one entry per row), a radian `delta` +vector or `nothing` when the table has no `delta` column, the `nrow × ncol` matrix of +the value columns and their names. The file suffix picks the format: `.arrow` (angle +columns, a per-row list column `values`, the names in the `columns` metadata entry) or +anything else CSV (a header line naming every column, found case-insensitively in any +order). Angles are stored in degrees. """ function read_node_table(path::AbstractString) - if endswith(String(path), ".arrow") - # bytes, not the mmap Arrow.Table(path) takes: Windows locks a mapped file - table = Arrow.Table(read(String(path))) - values = Matrix{Float64}(undef, length(table.alpha), length(first(table.values))) - for k in axes(values, 1) - @inbounds values[k, :] .= table.values[k] - end - return deg2rad.(table.alpha), deg2rad.(table.delta), values + names, data = endswith(String(path), ".arrow") ? read_arrow_columns(path) : + read_csv_columns(path) + lowercase_names = lowercase.(names) + alpha_col = findfirst(==("alpha"), lowercase_names) + alpha_col === nothing && error("Table $path has no alpha column") + delta_col = findfirst(==("delta"), lowercase_names) + value_cols = setdiff(eachindex(names), (alpha_col, delta_col)) + delta = delta_col === nothing ? nothing : deg2rad.(data[:, delta_col]) + return deg2rad.(data[:, alpha_col]), delta, data[:, value_cols], names[value_cols] +end + +""" + read_arrow_columns(path) -> (names, data) + +Read an Arrow aero table into its column names and one `Float64` matrix: the angle +columns, then the list column `values` spread under the names its `columns` metadata +entry holds, `n0, n1, …` where it has none. +""" +function read_arrow_columns(path::AbstractString) + # bytes, not the mmap Arrow.Table(path) takes: Windows locks a mapped file + table = Arrow.Table(read(String(path))) + angles = filter(!=(:values), Arrow.names(table)) + n_value = length(first(table.values)) + metadata = Arrow.getmetadata(table) + columns = metadata === nothing || !haskey(metadata, "columns") ? + ["n$(j - 1)" for j in 1:n_value] : split(metadata["columns"], ',') + data = Matrix{Float64}(undef, length(table.values), length(angles) + n_value) + for (j, angle) in enumerate(angles) + data[:, j] .= getproperty(table, angle) + end + for k in axes(data, 1) + @inbounds data[k, (length(angles) + 1):end] .= table.values[k] end - lines = [l for l in readlines(String(path)) if !isempty(strip(l))] - n_row = length(lines) - 1 - n_col = count(==(','), lines[1]) + 1 - alpha = Vector{Float64}(undef, n_row) - delta = Vector{Float64}(undef, n_row) - values = Matrix{Float64}(undef, n_row, n_col - 2) - for k in 1:n_row + return [String.(angles); String.(columns)], data +end + +""" + read_csv_columns(path) -> (names, data) + +Read a CSV table with one header line into its column names and one `Float64` matrix. +Throws on a row whose field count differs from the header's. +""" +function read_csv_columns(path::AbstractString) + lines = [strip(l) for l in readlines(String(path)) if !isempty(strip(l))] + isempty(lines) && error("Table $path is empty") + names = String.(strip.(split(lines[1], ','))) + data = Matrix{Float64}(undef, length(lines) - 1, length(names)) + for k in axes(data, 1) fields = split(lines[k + 1], ',') - alpha[k] = deg2rad(parse(Float64, fields[1])) - delta[k] = deg2rad(parse(Float64, fields[2])) - @inbounds for j in 3:n_col - values[k, j - 2] = parse(Float64, fields[j]) + length(fields) == length(names) || + error("Row $k of $path has $(length(fields)) fields, header $(length(names))") + @inbounds for j in eachindex(fields) + data[k, j] = parse(Float64, fields[j]) end end - return alpha, delta, values + return names, data end """ - write_node_rows(path, alpha, delta, values) -> path + write_node_rows(path, alpha, delta, values; columns) -> path -Write a per-node aero table from radian `alpha`/`delta` vectors (one entry per row) -and a `nrow × n_node` value matrix. The file suffix picks the format, matching -[`read_node_table`](@ref): `.arrow` for the binary form, anything else CSV. Angles -are written in degrees either way. +Write an aero table from a radian `alpha` vector (one entry per row), a radian `delta` +vector or `nothing` for a table without a `delta` column, and a `nrow × ncol` value +matrix whose columns are named `columns` (default `n0, n1, …`). The file suffix picks +the format, matching [`read_node_table`](@ref): `.arrow`, or anything else CSV at 16 +significant digits. Angles are written in degrees at 16 significant digits either way. """ -function write_node_rows(path::AbstractString, alpha, delta, values) +function write_node_rows(path::AbstractString, alpha, delta, values; + columns=["n$(j - 1)" for j in axes(values, 2)]) + degrees(angle) = round.(rad2deg.(angle); sigdigits=16) + angles = delta === nothing ? (alpha=degrees(alpha),) : + (alpha=degrees(alpha), delta=degrees(delta)) if endswith(String(path), ".arrow") Arrow.write(String(path), - (alpha=rad2deg.(alpha), delta=rad2deg.(delta), - values=[values[k, :] for k in axes(values, 1)])) + merge(angles, (values=[values[k, :] for k in axes(values, 1)],)); + metadata=["columns" => join(columns, ",")]) return path end + data = hcat(angles..., values) open(String(path), "w") do io - println(io, "alpha,delta," * join(("n$(j - 1)" for j in axes(values, 2)), ",")) - for k in axes(values, 1) - row = [rad2deg(alpha[k]), rad2deg(delta[k])] - append!(row, @view values[k, :]) - println(io, join(row, ",")) + println(io, join((keys(angles)..., columns...), ",")) + for k in axes(data, 1) + println(io, csv_fields(@view data[k, :])) end end return path @@ -204,12 +249,14 @@ end """ convert_node_table(src, dst) -> dst -Rewrite a per-node aero table in the format `dst`'s suffix names. Use to move an -existing dataset between CSV and Arrow without re-running the (slow) airfoil solver -that produced it. +Rewrite an aero table, a per-node `Cp`/`cf` table or a polar, in the format `dst`'s +suffix names, keeping its column names. Use to move an existing dataset between CSV and +Arrow without re-running the (slow) airfoil solver that produced it. """ -convert_node_table(src::AbstractString, dst::AbstractString) = - write_node_rows(dst, read_node_table(src)...) +function convert_node_table(src::AbstractString, dst::AbstractString) + alpha, delta, values, columns = read_node_table(src) + return write_node_rows(dst, alpha, delta, values; columns) +end """ read_section_aero(dat_file, cp_file, cf_file) -> Union{Nothing, SectionAero} diff --git a/src/surfplan_adapter/SurfplanAdapter.jl b/src/surfplan_adapter/SurfplanAdapter.jl index a723daab..520dfe19 100644 --- a/src/surfplan_adapter/SurfplanAdapter.jl +++ b/src/surfplan_adapter/SurfplanAdapter.jl @@ -4,6 +4,7 @@ using LinearAlgebra import YAML using ..AirfoilAero: shrink_wrap, ShrinkWrap, read_dat_coordinates, generate_airfoils, write_geometry_yaml, NeuralFoilSolver, AbstractAirfoilSolver +using ..ObjAdapter: migrate_node_tables """ surfplan_to_aero_yaml(adapter_dir, output_dir; aero_solver=NeuralFoilSolver(), @@ -25,14 +26,16 @@ default, [`XFoilSolver`](@ref VortexStepMethod.AirfoilAero.XFoilSolver) opt-in). Sections that share an airfoil generate its tables once. `Re` defaults to the export's `wing_airfoils.reynolds`; `alpha_range` defaults to the full `-180:1:180` sweep rather than the export's narrow polar range. -`table_format` writes the per-node surface tables as `:csv` (default, readable) or -`:arrow` (binary, an order of magnitude faster to load). +`table_format` writes the per-node surface tables and the polars as `:csv` (default, +readable) or `:arrow` (binary, an order of magnitude faster to load). The `.txt` → adapter-YAML step (the upstream Python `SurfplanAdapter`) is the documented prerequisite. Load the result with `Wing(geometry_yaml_path)`. -An existing `geometry.yaml` in `output_dir` is reused as-is, skipping the expensive -polar generation; set `force=true` to regenerate the polars. +An existing `geometry.yaml` in `output_dir` is reused, skipping the expensive polar +generation, with its tables converted to `table_format` where they are in another +([`migrate_node_tables`](@ref VortexStepMethod.ObjAdapter.migrate_node_tables)); set +`force=true` to regenerate the polars. """ function surfplan_to_aero_yaml(adapter_dir::AbstractString, output_dir::AbstractString; aero_solver::AbstractAirfoilSolver=NeuralFoilSolver(), @@ -43,7 +46,7 @@ function surfplan_to_aero_yaml(adapter_dir::AbstractString, output_dir::Abstract yaml_path = joinpath(output_dir, "geometry.yaml") if !force && isfile(yaml_path) verbose && @info "Reusing existing $yaml_path (pass force=true to regenerate)" - return yaml_path + return migrate_node_tables(yaml_path, output_dir, table_format; verbose) end data = YAML.load_file(joinpath(adapter_dir, "aero_geometry.yaml")) wing_sections = data["wing_sections"]["data"] diff --git a/src/yaml_geometry.jl b/src/yaml_geometry.jl index 456c3656..043420cb 100644 --- a/src/yaml_geometry.jl +++ b/src/yaml_geometry.jl @@ -28,140 +28,55 @@ span_order_key(section::WingSectionData) = section.LE_y end """ - load_polar_data(csv_file_path::String) -> Tuple{Union{Nothing, Tuple{Vector{Float64}, Vector{Float64}, Vector{Float64}, Vector{Float64}}}, Symbol} + assemble_polar_matrix(alpha, delta, coefficients) -> (alphas, deltas, grids...) -Load aerodynamic polar data from a CSV file using only `readlines`. - -The CSV file must contain a header row with columns for `alpha`, `cl`, `cd`, and `cm` (case-insensitive, order arbitrary). Each subsequent row should contain numeric values for these columns. - -# Arguments -- `csv_file_path::String`: Path to the CSV file containing polar data. - -# Returns -- A tuple `(aero_data, model_type)` where: - - `aero_data`: A tuple of vectors `(alpha, cl, cd, cm)` if the file is valid, or `nothing` if invalid or missing. - - `alpha`: Angle of attack in degrees (converted to radians internally). - - `cl`: Lift coefficient. - - `cd`: Drag coefficient. - - `cm`: Moment coefficient. - - `model_type`: `POLAR_VECTORS` if data is loaded, or `INVISCID` if not. - -# Behavior -- If the file is missing, empty, or invalid, a warning is issued and `(nothing, INVISCID)` is returned. - -# Example -```julia -# Create a YAML-based wing from configuration file -wing = Wing( - "path/to/wing_config.yaml"; - n_panels=40, - n_groups=4 -) -``` +Scatter long-format polar rows (radian `alpha`/`delta` per row, one coefficient per +column of `coefficients`) onto the sorted unique `alphas × deltas` grid, one matrix per +coefficient. Grid points without a row stay `NaN`. """ -function assemble_polar_matrix(dv::Dict{String,Vector{Float64}}) - alphas = sort(unique(dv["alpha"])) - deltas = sort(unique(dv["delta"])) +function assemble_polar_matrix(alpha, delta, coefficients::AbstractMatrix) + alphas = sort(unique(alpha)) + deltas = sort(unique(delta)) ai = Dict(a => i for (i, a) in enumerate(alphas)) di = Dict(d => j for (j, d) in enumerate(deltas)) - cl = fill(NaN, length(alphas), length(deltas)) - cd = fill(NaN, length(alphas), length(deltas)) - cm = fill(NaN, length(alphas), length(deltas)) - for k in eachindex(dv["alpha"]) - i, j = ai[dv["alpha"][k]], di[dv["delta"][k]] - cl[i, j], cd[i, j], cm[i, j] = dv["cl"][k], dv["cd"][k], dv["cm"][k] + grids = [fill(NaN, length(alphas), length(deltas)) for _ in axes(coefficients, 2)] + for k in eachindex(alpha), (c, grid) in enumerate(grids) + grid[ai[alpha[k]], di[delta[k]]] = coefficients[k, c] end - return (deg2rad.(alphas), deg2rad.(deltas), cl, cd, cm) + return (alphas, deltas, grids...) end -function load_polar_data(csv_file_path::String) - # Return early for empty path - if isempty(csv_file_path) - @warn "Empty CSV file path provided" +""" + load_polar_data(path::String) -> (aero_data, model) + +Load an airfoil polar table, CSV or Arrow as the suffix of `path` says (see +[`read_node_table`](@ref)), with columns `alpha`, `cl`, `cd` and `cm` (case-insensitive, +any order, alpha in degrees). A `delta` column makes it a long-format `POLAR_MATRICES` +table, returned as `(alpha, delta, cl, cd, cm)` on its sorted grid; without one it is +`POLAR_VECTORS` `(alpha, cl, cd, cm)`. Angles come back in radians. A missing, empty or +malformed file warns and returns `(nothing, INVISCID)`. +""" +function load_polar_data(path::String) + if !isfile(path) + @warn "Polar file not found: \"$path\"" return (nothing, INVISCID) end - - # Check if file exists - if !isfile(csv_file_path) - @warn "CSV file not found: $csv_file_path" + alpha, delta, values, columns = try + read_node_table(path) + catch e + @warn "Error reading polar file $path: $e" return (nothing, INVISCID) end - - try - # Read all lines from the file - lines = readlines(csv_file_path) - - # Check if file is empty - if isempty(lines) - @warn "CSV file is empty: $csv_file_path" - return (nothing, INVISCID) - end - - # Parse header - make case insensitive - header_line = strip(lines[1]) - if isempty(header_line) - @warn "CSV file has empty header: $csv_file_path" - return (nothing, INVISCID) - end - - # Split header and normalize to lowercase. A `delta` column marks the - # long-format `(alpha, delta)` polar, loaded as POLAR_MATRICES. - header_parts = map(strip ∘ lowercase, split(header_line, ',')) - has_delta = "delta" in header_parts - wanted = has_delta ? ["alpha", "delta", "cl", "cd", "cm"] : - ["alpha", "cl", "cd", "cm"] - col_indices = Dict{String, Int}() - for (i, col_name) in enumerate(header_parts) - col_name in wanted && (col_indices[col_name] = i) - end - - missing_cols = setdiff(wanted, keys(col_indices)) - if !isempty(missing_cols) - @warn "CSV file missing required columns: $(join(missing_cols, ", ")) in $csv_file_path" - return (nothing, INVISCID) - end - - # Parse data rows - data_vectors = Dict(col => Float64[] for col in wanted) - for line_num in eachindex(lines) - line_num == firstindex(lines) && continue - line = strip(lines[line_num]) - isempty(line) && continue # Skip empty lines - - parts = split(line, ',') - if length(parts) != length(header_parts) - @warn "Line $line_num has incorrect number of columns in $csv_file_path" - return (nothing, INVISCID) # Strict: any malformed data rejects the file - end - - try - for col in wanted - push!(data_vectors[col], parse(Float64, strip(parts[col_indices[col]]))) - end - catch e - @warn "Failed to parse line $line_num in $csv_file_path: $e" - return (nothing, INVISCID) # Strict: any parsing error rejects the file - end - end - - # Check if we got any valid data - if isempty(data_vectors["alpha"]) - @warn "No valid data rows found in $csv_file_path" - return (nothing, INVISCID) - end - - has_delta && return (assemble_polar_matrix(data_vectors), POLAR_MATRICES) - - # Convert alpha from degrees to radians and create tuple - alpha_rad = deg2rad.(data_vectors["alpha"]) - aero_data = (alpha_rad, data_vectors["cl"], data_vectors["cd"], data_vectors["cm"]) - - return (aero_data, POLAR_VECTORS) - - catch e - @warn "Error reading CSV file $csv_file_path: $e" + wanted = [findfirst(==(name), lowercase.(columns)) for name in ("cl", "cd", "cm")] + if any(isnothing, wanted) || isempty(alpha) + @warn "Polar file $path needs alpha, cl, cd and cm columns and a data row" return (nothing, INVISCID) end + coefficients = values[:, wanted] + delta === nothing || + return (assemble_polar_matrix(alpha, delta, coefficients), POLAR_MATRICES) + return (alpha, coefficients[:, 1], coefficients[:, 2], coefficients[:, 3]), + POLAR_VECTORS end """ diff --git a/test/airfoil_aero/test_airfoil_aero.jl b/test/airfoil_aero/test_airfoil_aero.jl index 63889400..f3b94a94 100644 --- a/test/airfoil_aero/test_airfoil_aero.jl +++ b/test/airfoil_aero/test_airfoil_aero.jl @@ -321,6 +321,55 @@ end @test first(VortexStepMethod.read_aero_matrix(labelled_csv)) ≈ cd end +@testset "an .arrow polar is an Arrow table that loads like its CSV twin" begin + alpha_range = deg2rad.(-5:5:15) + delta_range = deg2rad.(-3:3:3) + cl = [0.1 * a + 0.03 * d for a in -5:5:15, d in -3:3:3] + cd = [0.0093 + 2e-4 * a + 2e-5 * d for a in -5:5:15, d in -3:3:3] + cm = [0.06 - 0.01 * d for a in -5:5:15, d in -3:3:3] + sols = [SectionSolution(alpha_range[i], cl[i, 1], cd[i, 1], cm[i, 1], 1.0, + Float64[], Float64[], Float64[], Float64[]) + for i in eachindex(alpha_range)] + result = AirfoilAero.NeuralFoilResult(collect(-5.0:5:15), cl[:, 1], cd[:, 1], + cm[:, 1], ones(5)) + writers = Dict( + "matrix" => path -> write_polar_matrix_csv(path, alpha_range, delta_range, + cl, cd, cm), + "vectors" => path -> write_polar_csv(path, sols), + "neuralfoil" => path -> write_polar_csv(path, result)) + work = mktempdir() + for (name, write_polar) in writers + csv = write_polar(joinpath(work, "$name.csv")) + arrow = write_polar(joinpath(work, "$name.arrow")) + table = VortexStepMethod.Arrow.Table(read(arrow)) + @test VortexStepMethod.Arrow.getmetadata(table)["columns"] == + (name == "vectors" || name == "neuralfoil" ? "Cd,Cs,Cl,Cm" : "Cl,Cd,Cm") + converted = VortexStepMethod.convert_node_table(csv, + joinpath(work, "c_$name.arrow")) + csv_data, csv_model = load_polar_data(csv) + for (data, model) in (load_polar_data(arrow), load_polar_data(converted)) + @test model == csv_model + @test data[1] == csv_data[1] + @test all(map(≈, data, csv_data)) + end + _, _, _, columns = VortexStepMethod.read_node_table(converted) + @test columns == (name == "matrix" ? ["Cl", "Cd", "Cm"] : ["Cd", "Cs", "Cl", "Cm"]) + end + (_, _, _, cd_back, _), model = load_polar_data(joinpath(work, "matrix.arrow")) + @test model == VortexStepMethod.POLAR_MATRICES + @test cd_back ≈ cd +end + +@testset "an Arrow node table written before named columns still reads" begin + path = joinpath(mktempdir(), "legacy_cp.arrow") + VortexStepMethod.Arrow.write(path, (alpha=[-5.0, 5.0], delta=[0.0, 0.0], + values=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])) + alpha, delta, values, columns = VortexStepMethod.read_node_table(path) + @test alpha ≈ deg2rad.([-5.0, 5.0]) && delta == [0.0, 0.0] + @test values == [1.0 2.0 3.0; 4.0 5.0 6.0] + @test columns == ["n0", "n1", "n2"] +end + @testset "turn_trailing_edge! legacy crease cleanup" begin x, y = read_dat_coordinates(joinpath(@__DIR__, "data", "test_airfoil.dat")) crease_frac = 0.7 diff --git a/test/obj_adapter/test_obj_adapter.jl b/test/obj_adapter/test_obj_adapter.jl index 50732486..0d15005c 100644 --- a/test/obj_adapter/test_obj_adapter.jl +++ b/test/obj_adapter/test_obj_adapter.jl @@ -1,6 +1,7 @@ using Test using VortexStepMethod.ObjAdapter using VortexStepMethod +using VortexStepMethod: load_polar_data using VortexStepMethod.AirfoilAero: NeuralFoilSolver using LinearAlgebra import YAML @@ -71,11 +72,13 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", arrow_yaml = obj_to_yaml(obj_path, outdir; n_sections=3, Re=5e5, verbose=false, table_format=:arrow) info = Dict(YAML.load_file(arrow_yaml)["wing_airfoils"]["data"][1][3]) - @test endswith(info["cp_file"], ".arrow") - @test endswith(info["cf_file"], ".arrow") - @test isfile(joinpath(outdir, info["cp_file"])) - @test isfile(joinpath(outdir, info["cf_file"])) - @test isfile(joinpath(outdir, csv_info["cp_file"])) + for key in ("cp_file", "cf_file", "csv_file_path") + @test endswith(info[key], ".arrow") + @test isfile(joinpath(outdir, info[key])) + @test isfile(joinpath(outdir, csv_info[key])) + end + @test isequal(load_polar_data(joinpath(outdir, info["csv_file_path"])), + load_polar_data(joinpath(outdir, csv_info["csv_file_path"]))) @test Wing(arrow_yaml; n_panels=4) isa Wing # already in that format: nothing to convert, YAML untouched diff --git a/test/surfplan/test_surfplan.jl b/test/surfplan/test_surfplan.jl index 8d34cf03..c906c18d 100644 --- a/test/surfplan/test_surfplan.jl +++ b/test/surfplan/test_surfplan.jl @@ -38,6 +38,19 @@ import YAML wing = Wing(yaml_path) @test wing isa Wing + reused = surfplan_to_aero_yaml(adapter_dir, output_dir; verbose=false, + table_format=:arrow) + info = Dict(YAML.load_file(reused)["wing_airfoils"]["data"][1][3]) + @test all(endswith(info[key], ".arrow") for key in ("cp_file", "cf_file", + "csv_file_path")) + @test Wing(reused) isa Wing + + arrow_dir = mktempdir() + surfplan_to_aero_yaml(adapter_dir, arrow_dir; + aero_solver=NeuralFoilSolver(model_size="medium"), + alpha_range=-4:4:4, Re=5.0e5, verbose=false, table_format=:arrow) + @test isfile(joinpath(arrow_dir, "polars", "1.arrow")) + # a missing dat_file_path is a hard error (the profiles are required) aero["wing_airfoils"]["data"][1][3] = Dict("dat_file_path" => "") YAML.write_file(joinpath(adapter_dir, "aero_geometry.yaml"), aero) From e0a1f58a441337d8ab8c53499e91b339aead609d Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:22:28 +0200 Subject: [PATCH 2/4] Address review of #372: no value-matrix copy, plain polar columns, docs read_node_table returns a view of the value columns instead of a copy, and write_node_rows formats each CSV row without first hcat-ing the angles onto the values. write_polar_csv collects its four columns with plain comprehensions. The polar round-trip test keeps each writer's expected columns beside it. The pipeline docs name the polar and node table suffix as {table_format}, and the changelog states that a CSV polar with a non-numeric column now loads as INVISCID, pinned by a test. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++++--- docs/src/airfoil_pipeline.md | 7 ++++--- src/airfoil_aero/airfoil_solvers/common.jl | 6 ++++-- src/section_aero.jl | 8 ++++---- test/airfoil_aero/test_airfoil_aero.jl | 14 +++++++------- test/yaml_geometry/test_load_polar_data.jl | 7 +++++++ 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21750bd9..0f051786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,12 @@ and at flap deflection `delta`, in one figure instead of one coefficient per call. - `linearize` takes a `BodyAerodynamics` with more than one wing; `theta_idxs` and `delta_idxs` then run over the unrefined sections of all wings in order. -- `table_format=:arrow` writes the polars as `.arrow` too, not only the per-node `Cp`/`cf` +- `table_format=:arrow` writes the polars as `.arrow`, like the per-node `Cp`/`cf` tables: `write_polar_csv` and `write_polar_matrix_csv` write Arrow for an `.arrow` path - and `load_polar_data` reads it. `obj_to_yaml` and now also `surfplan_to_aero_yaml` - convert a reused dataset's polars along with its node tables. CSV polars load as before. + and `load_polar_data` reads it. `obj_to_yaml` and `surfplan_to_aero_yaml` convert a + reused dataset's polars and node tables to `table_format`. A CSV polar loads as before + when every column is numeric; one with a non-numeric column warns and loads as + `INVISCID`. ### Changed diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index 9b8ae6c0..2b87c564 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -97,9 +97,10 @@ For each unique airfoil id `j`, `obj_to_yaml` writes into `output_dir`: - `airfoils/{j}_d{tag}.dat` — each deflected shape, when a `delta_range` is given. The `{tag}` encodes the deflection in degrees (`m` for a minus sign, `p` for the decimal point), e.g. `_d5.dat` for 5°, `_dm3.dat` for −3°, `_d2p5.dat` for 2.5° -- `polars/{j}.csv` — the generated polar (`POLAR_VECTORS` or `POLAR_MATRICES`) -- `pressure/{j}_cp.csv`, `pressure/{j}_cf.csv` — surface pressure and skin friction per - contour node over the `alpha × delta` grid, what pressure integration reads +- `polars/{j}.{table_format}` — the generated polar (`POLAR_VECTORS` or `POLAR_MATRICES`) +- `pressure/{j}_cp.{table_format}`, `pressure/{j}_cf.{table_format}` — surface pressure + and skin friction per contour node over the `alpha × delta` grid, what pressure + integration reads - `geometry.yaml` — `wing_sections` (leading/trailing-edge points) plus `wing_airfoils` (each section's `type` and the table paths above). `geometry_path` writes the YAML somewhere else, in which case the emitted table references carry the path from the YAML's diff --git a/src/airfoil_aero/airfoil_solvers/common.jl b/src/airfoil_aero/airfoil_solvers/common.jl index f6d96de9..f24b340c 100644 --- a/src/airfoil_aero/airfoil_solvers/common.jl +++ b/src/airfoil_aero/airfoil_solvers/common.jl @@ -41,8 +41,10 @@ NeuralFoil and XFoil sweeps. """ function write_polar_csv(filepath::String, sols::Vector{SectionSolution}) converged = filter(sol -> !isnan(sol.cl), sols) - alpha, cd, cl, cm = ([getfield(sol, name) for sol in converged] - for name in (:alpha, :cd, :cl, :cm)) + alpha = [sol.alpha for sol in converged] + cd = [sol.cd for sol in converged] + cl = [sol.cl for sol in converged] + cm = [sol.cm for sol in converged] return write_node_rows(filepath, alpha, nothing, [cd zero(cd) cl cm]; columns=["Cd", "Cs", "Cl", "Cm"]) end diff --git a/src/section_aero.jl b/src/section_aero.jl index 2b856f0f..e9a33104 100644 --- a/src/section_aero.jl +++ b/src/section_aero.jl @@ -166,7 +166,7 @@ function read_node_table(path::AbstractString) delta_col = findfirst(==("delta"), lowercase_names) value_cols = setdiff(eachindex(names), (alpha_col, delta_col)) delta = delta_col === nothing ? nothing : deg2rad.(data[:, delta_col]) - return deg2rad.(data[:, alpha_col]), delta, data[:, value_cols], names[value_cols] + return deg2rad.(data[:, alpha_col]), delta, view(data, :, value_cols), names[value_cols] end """ @@ -236,11 +236,11 @@ function write_node_rows(path::AbstractString, alpha, delta, values; metadata=["columns" => join(columns, ",")]) return path end - data = hcat(angles..., values) open(String(path), "w") do io println(io, join((keys(angles)..., columns...), ",")) - for k in axes(data, 1) - println(io, csv_fields(@view data[k, :])) + for k in axes(values, 1) + println(io, csv_fields(angle[k] for angle in angles), ",", + csv_fields(@view values[k, :])) end end return path diff --git a/test/airfoil_aero/test_airfoil_aero.jl b/test/airfoil_aero/test_airfoil_aero.jl index f3b94a94..9bea4be0 100644 --- a/test/airfoil_aero/test_airfoil_aero.jl +++ b/test/airfoil_aero/test_airfoil_aero.jl @@ -333,17 +333,17 @@ end result = AirfoilAero.NeuralFoilResult(collect(-5.0:5:15), cl[:, 1], cd[:, 1], cm[:, 1], ones(5)) writers = Dict( - "matrix" => path -> write_polar_matrix_csv(path, alpha_range, delta_range, - cl, cd, cm), - "vectors" => path -> write_polar_csv(path, sols), - "neuralfoil" => path -> write_polar_csv(path, result)) + "matrix" => (path -> write_polar_matrix_csv(path, alpha_range, delta_range, + cl, cd, cm), ["Cl", "Cd", "Cm"]), + "vectors" => (path -> write_polar_csv(path, sols), ["Cd", "Cs", "Cl", "Cm"]), + "neuralfoil" => (path -> write_polar_csv(path, result), ["Cd", "Cs", "Cl", "Cm"])) work = mktempdir() - for (name, write_polar) in writers + for (name, (write_polar, expected_columns)) in writers csv = write_polar(joinpath(work, "$name.csv")) arrow = write_polar(joinpath(work, "$name.arrow")) table = VortexStepMethod.Arrow.Table(read(arrow)) @test VortexStepMethod.Arrow.getmetadata(table)["columns"] == - (name == "vectors" || name == "neuralfoil" ? "Cd,Cs,Cl,Cm" : "Cl,Cd,Cm") + join(expected_columns, ",") converted = VortexStepMethod.convert_node_table(csv, joinpath(work, "c_$name.arrow")) csv_data, csv_model = load_polar_data(csv) @@ -353,7 +353,7 @@ end @test all(map(≈, data, csv_data)) end _, _, _, columns = VortexStepMethod.read_node_table(converted) - @test columns == (name == "matrix" ? ["Cl", "Cd", "Cm"] : ["Cd", "Cs", "Cl", "Cm"]) + @test columns == expected_columns end (_, _, _, cd_back, _), model = load_polar_data(joinpath(work, "matrix.arrow")) @test model == VortexStepMethod.POLAR_MATRICES diff --git a/test/yaml_geometry/test_load_polar_data.jl b/test/yaml_geometry/test_load_polar_data.jl index 190ada25..ce9e648e 100644 --- a/test/yaml_geometry/test_load_polar_data.jl +++ b/test/yaml_geometry/test_load_polar_data.jl @@ -115,4 +115,11 @@ using Logging @test aero_model == INVISCID @test aero_data === nothing end + + @testset "A non-numeric extra column rejects the file" begin + write(test_csv_path, "alpha,cl,cd,cm,source\n0.0,1.0,0.01,0.0,xfoil\n") + aero_data, aero_model = suppress_warnings(() -> load_polar_data(test_csv_path)) + @test aero_model == INVISCID + @test aero_data === nothing + end end From fb5e74c8619f1be1b2493e01c796f418c2a6b506 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:53:14 +0200 Subject: [PATCH 3/4] Rename the polar writers and csv_file_path, resolve neuralfoil polars as table_format write_polar_csv is write_polar and write_polar_matrix_csv is write_polar_matrix, and the geometry YAML names the polar polar_file_path; csv_file_path is still read, and migrate_node_tables writes it back under the new key. resolve_airfoil and resolve_aero_geometry take table_format, so the awesIO neuralfoil route writes .arrow polars as well. Closes #373. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 ++++-- data/TUDELFT_V3_KITE/aero_geometry.yaml | 40 +++++++++--------- .../aero_geometry_coarse_discretisation.yaml | 40 +++++++++--------- data/pyramid_model/wing_geometry.yaml | 2 +- docs/src/airfoil_pipeline.md | 5 ++- docs/src/private_functions.md | 4 +- src/airfoil_aero/AirfoilAero.jl | 2 +- src/airfoil_aero/airfoil_io.jl | 41 ++++++++++--------- src/airfoil_aero/airfoil_solvers/common.jl | 8 ++-- src/airfoil_aero/geometry_gen.jl | 6 +-- src/airfoil_aero/geometry_yaml.jl | 2 +- src/obj_adapter/obj_to_yaml.jl | 26 +++++++----- src/yaml_geometry.jl | 28 ++++++------- test/airfoil_aero/test_airfoil_aero.jl | 18 ++++---- test/body_aerodynamics/test_wing.yaml | 2 +- test/data/solver/wings/solver_test_wing.yaml | 2 +- test/obj_adapter/test_obj_adapter.jl | 37 ++++++++++++++--- test/solver/solver_test_wing.yaml | 2 +- test/surfplan/test_surfplan.jl | 2 +- test/yaml_geometry/complex_wing.yaml | 4 +- test/yaml_geometry/simple_wing.yaml | 2 +- test/yaml_geometry/test_wing_constructor.jl | 18 ++++++-- 22 files changed, 178 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f051786..3a920ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,19 @@ - `linearize` takes a `BodyAerodynamics` with more than one wing; `theta_idxs` and `delta_idxs` then run over the unrefined sections of all wings in order. - `table_format=:arrow` writes the polars as `.arrow`, like the per-node `Cp`/`cf` - tables: `write_polar_csv` and `write_polar_matrix_csv` write Arrow for an `.arrow` path - and `load_polar_data` reads it. `obj_to_yaml` and `surfplan_to_aero_yaml` convert a - reused dataset's polars and node tables to `table_format`. A CSV polar loads as before + tables: `write_polar` and `write_polar_matrix` write Arrow for an `.arrow` path and + `load_polar_data` reads it. `resolve_aero_geometry` takes `table_format` too, for the + polars it generates. `obj_to_yaml` and `surfplan_to_aero_yaml` convert a reused + dataset's polars and node tables to `table_format`. A CSV polar loads as before when every column is numeric; one with a non-numeric column warns and loads as `INVISCID`. ### Changed +- BREAKING: `write_polar_csv` is `write_polar` and `write_polar_matrix_csv` is + `write_polar_matrix`. The geometry YAML names the polar `polar_file_path`; the old + key `csv_file_path` is still read, and `obj_to_yaml` rewrites it when it migrates a + dataset. - BREAKING: `ObjAdapter.center_to_com!`, `calculate_inertia_tensor` and `calc_inertia_y_rotation` are removed. Mesh mass properties are computed by SymbolicAWEModels, which reads the mesh with `read_faces`. @@ -59,7 +64,7 @@ ### Fixed -- `write_polar_csv` for `SectionSolution`s, `write_polar_matrix_csv` and `write_aero_matrix` +- `write_polar` for `SectionSolution`s, `write_polar_matrix` and `write_aero_matrix` write coefficients at 16 significant digits instead of 4 decimals, so a `POLAR_MATRICES` table carries the drag response to a small flap deflection. Regenerate existing tables to benefit. diff --git a/data/TUDELFT_V3_KITE/aero_geometry.yaml b/data/TUDELFT_V3_KITE/aero_geometry.yaml index 1c73c172..6918cd72 100644 --- a/data/TUDELFT_V3_KITE/aero_geometry.yaml +++ b/data/TUDELFT_V3_KITE/aero_geometry.yaml @@ -73,7 +73,7 @@ wing_airfoils: # Average wind tunnel: 9 (standard "e^9 method") # Dirty wind tunnel: 4–8 # - polars: - # csv_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) + # polar_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) # - masure_regression: # t, eta, kappa, delta, lamba, phi: Regression parameters # - inviscid: @@ -86,22 +86,22 @@ wing_airfoils: # The polars were generated using 2D RANS CFD simulations at Re=5e5 # And fitted using PCHIP interpolation # See --> https://repository.tudelft.nl/record/uuid:865d59fc-ccff-462e-9bac-e81725f1c0c9 - - [1, polars, {csv_file_path: "polars_CFD/1.csv"}] - - [2, polars, {csv_file_path: "polars_CFD/2.csv"}] - - [3, polars, {csv_file_path: "polars_CFD/3.csv"}] - - [4, polars, {csv_file_path: "polars_CFD/4.csv"}] - - [5, polars, {csv_file_path: "polars_CFD/5.csv"}] - - [6, polars, {csv_file_path: "polars_CFD/6.csv"}] - - [7, polars, {csv_file_path: "polars_CFD/7.csv"}] - - [8, polars, {csv_file_path: "polars_CFD/8.csv"}] - - [9, polars, {csv_file_path: "polars_CFD/9.csv"}] - - [10, polars, {csv_file_path: "polars_CFD/10.csv"}] - - [11, polars, {csv_file_path: "polars_CFD/11.csv"}] - - [12, polars, {csv_file_path: "polars_CFD/12.csv"}] - - [13, polars, {csv_file_path: "polars_CFD/13.csv"}] - - [14, polars, {csv_file_path: "polars_CFD/14.csv"}] - - [15, polars, {csv_file_path: "polars_CFD/15.csv"}] - - [16, polars, {csv_file_path: "polars_CFD/16.csv"}] - - [17, polars, {csv_file_path: "polars_CFD/17.csv"}] - - [18, polars, {csv_file_path: "polars_CFD/18.csv"}] - - [19, polars, {csv_file_path: "polars_CFD/19.csv"}] \ No newline at end of file + - [1, polars, {polar_file_path: "polars_CFD/1.csv"}] + - [2, polars, {polar_file_path: "polars_CFD/2.csv"}] + - [3, polars, {polar_file_path: "polars_CFD/3.csv"}] + - [4, polars, {polar_file_path: "polars_CFD/4.csv"}] + - [5, polars, {polar_file_path: "polars_CFD/5.csv"}] + - [6, polars, {polar_file_path: "polars_CFD/6.csv"}] + - [7, polars, {polar_file_path: "polars_CFD/7.csv"}] + - [8, polars, {polar_file_path: "polars_CFD/8.csv"}] + - [9, polars, {polar_file_path: "polars_CFD/9.csv"}] + - [10, polars, {polar_file_path: "polars_CFD/10.csv"}] + - [11, polars, {polar_file_path: "polars_CFD/11.csv"}] + - [12, polars, {polar_file_path: "polars_CFD/12.csv"}] + - [13, polars, {polar_file_path: "polars_CFD/13.csv"}] + - [14, polars, {polar_file_path: "polars_CFD/14.csv"}] + - [15, polars, {polar_file_path: "polars_CFD/15.csv"}] + - [16, polars, {polar_file_path: "polars_CFD/16.csv"}] + - [17, polars, {polar_file_path: "polars_CFD/17.csv"}] + - [18, polars, {polar_file_path: "polars_CFD/18.csv"}] + - [19, polars, {polar_file_path: "polars_CFD/19.csv"}] \ No newline at end of file diff --git a/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml b/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml index b587c039..f122cfbb 100644 --- a/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml +++ b/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml @@ -140,7 +140,7 @@ wing_airfoils: # Average wind tunnel: 9 (standard "e^9 method") # Dirty wind tunnel: 4–8 # - polars: - # csv_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) + # polar_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) # - measure_regression: # t, eta, kappa, delta, lambda, phi: Regression parameters # - inviscid: @@ -150,22 +150,22 @@ wing_airfoils: reynolds: !!float 1e6 # Reynolds number headers: [airfoil_id, type, info_dict] data: - - [1, polars, { csv_file_path: '2D_polars_drag_adjusted/1.csv' }] - - [2, polars, { csv_file_path: '2D_polars_drag_adjusted/2.csv' }] - - [3, polars, { csv_file_path: '2D_polars_drag_adjusted/3.csv' }] - - [4, polars, { csv_file_path: '2D_polars_drag_adjusted/4.csv' }] - - [5, polars, { csv_file_path: '2D_polars_drag_adjusted/5.csv' }] - - [6, polars, { csv_file_path: '2D_polars_drag_adjusted/6.csv' }] - - [7, polars, { csv_file_path: '2D_polars_drag_adjusted/7.csv' }] - - [8, polars, { csv_file_path: '2D_polars_drag_adjusted/8.csv' }] - - [9, polars, { csv_file_path: '2D_polars_drag_adjusted/9.csv' }] - - [10, polars, { csv_file_path: '2D_polars_drag_adjusted/10.csv' }] - - [11, polars, { csv_file_path: '2D_polars_drag_adjusted/11.csv' }] - - [12, polars, { csv_file_path: '2D_polars_drag_adjusted/12.csv' }] - - [13, polars, { csv_file_path: '2D_polars_drag_adjusted/13.csv' }] - - [14, polars, { csv_file_path: '2D_polars_drag_adjusted/14.csv' }] - - [15, polars, { csv_file_path: '2D_polars_drag_adjusted/15.csv' }] - - [16, polars, { csv_file_path: '2D_polars_drag_adjusted/16.csv' }] - - [17, polars, { csv_file_path: '2D_polars_drag_adjusted/17.csv' }] - - [18, polars, { csv_file_path: '2D_polars_drag_adjusted/18.csv' }] - - [19, polars, { csv_file_path: '2D_polars_drag_adjusted/19.csv' }] + - [1, polars, { polar_file_path: '2D_polars_drag_adjusted/1.csv' }] + - [2, polars, { polar_file_path: '2D_polars_drag_adjusted/2.csv' }] + - [3, polars, { polar_file_path: '2D_polars_drag_adjusted/3.csv' }] + - [4, polars, { polar_file_path: '2D_polars_drag_adjusted/4.csv' }] + - [5, polars, { polar_file_path: '2D_polars_drag_adjusted/5.csv' }] + - [6, polars, { polar_file_path: '2D_polars_drag_adjusted/6.csv' }] + - [7, polars, { polar_file_path: '2D_polars_drag_adjusted/7.csv' }] + - [8, polars, { polar_file_path: '2D_polars_drag_adjusted/8.csv' }] + - [9, polars, { polar_file_path: '2D_polars_drag_adjusted/9.csv' }] + - [10, polars, { polar_file_path: '2D_polars_drag_adjusted/10.csv' }] + - [11, polars, { polar_file_path: '2D_polars_drag_adjusted/11.csv' }] + - [12, polars, { polar_file_path: '2D_polars_drag_adjusted/12.csv' }] + - [13, polars, { polar_file_path: '2D_polars_drag_adjusted/13.csv' }] + - [14, polars, { polar_file_path: '2D_polars_drag_adjusted/14.csv' }] + - [15, polars, { polar_file_path: '2D_polars_drag_adjusted/15.csv' }] + - [16, polars, { polar_file_path: '2D_polars_drag_adjusted/16.csv' }] + - [17, polars, { polar_file_path: '2D_polars_drag_adjusted/17.csv' }] + - [18, polars, { polar_file_path: '2D_polars_drag_adjusted/18.csv' }] + - [19, polars, { polar_file_path: '2D_polars_drag_adjusted/19.csv' }] diff --git a/data/pyramid_model/wing_geometry.yaml b/data/pyramid_model/wing_geometry.yaml index 74e62c61..bdd50703 100644 --- a/data/pyramid_model/wing_geometry.yaml +++ b/data/pyramid_model/wing_geometry.yaml @@ -23,7 +23,7 @@ wing_airfoils: reynolds: 1e6 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "polars/V3_kite_midspan_airfoil_polar.csv"}] + - [1, polars, {polar_file_path: "polars/V3_kite_midspan_airfoil_polar.csv"}] bridle_nodes: # --------------------------------------------------------------- diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index 2b87c564..3daf3ab1 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -21,7 +21,7 @@ The conversion runs four stages per spanwise station: └── XFoil: coordinates → panel code (± repanel) │ 4. write ▼ - airfoils/*.dat + polars/*.csv + pressure/* + geometry.yaml → Wing(geometry.yaml) + airfoils/*.dat + polars/* + pressure/* + geometry.yaml → Wing(geometry.yaml) ``` ## 1. Slice @@ -97,7 +97,8 @@ For each unique airfoil id `j`, `obj_to_yaml` writes into `output_dir`: - `airfoils/{j}_d{tag}.dat` — each deflected shape, when a `delta_range` is given. The `{tag}` encodes the deflection in degrees (`m` for a minus sign, `p` for the decimal point), e.g. `_d5.dat` for 5°, `_dm3.dat` for −3°, `_d2p5.dat` for 2.5° -- `polars/{j}.{table_format}` — the generated polar (`POLAR_VECTORS` or `POLAR_MATRICES`) +- `polars/{j}.{table_format}` — the generated polar (`POLAR_VECTORS` or `POLAR_MATRICES`), + referenced from the YAML as `polar_file_path` - `pressure/{j}_cp.{table_format}`, `pressure/{j}_cf.{table_format}` — surface pressure and skin friction per contour node over the `alpha × delta` grid, what pressure integration reads diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index d07934c8..3c57dd16 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -218,8 +218,8 @@ create_2d_polars lei_poly_coeffs resolve_airfoil write_dat -write_polar_csv -write_polar_matrix_csv +write_polar +write_polar_matrix write_aero_matrix write_node_table flat_plate_cf diff --git a/src/airfoil_aero/AirfoilAero.jl b/src/airfoil_aero/AirfoilAero.jl index 01bcd0b1..de24e654 100644 --- a/src/airfoil_aero/AirfoilAero.jl +++ b/src/airfoil_aero/AirfoilAero.jl @@ -44,7 +44,7 @@ export SectionSolution, DeformedSection, deform_section, analyze_section, analyz export create_2d_polars, generate_aero_matrices, generate_section_aero, lei_poly_coeffs export generate_airfoil_aero, write_section_aero export turn_trailing_edge!, get_lower_upper -export read_dat_coordinates, write_dat, write_polar_csv, write_polar_matrix_csv +export read_dat_coordinates, write_dat, write_polar, write_polar_matrix export generate_polar_from_coordinates, generate_polar_from_dat, resolve_airfoil export write_geometry_yaml, write_yaml, generate_airfoils diff --git a/src/airfoil_aero/airfoil_io.jl b/src/airfoil_aero/airfoil_io.jl index ccf3e85a..d23a2fc7 100644 --- a/src/airfoil_aero/airfoil_io.jl +++ b/src/airfoil_aero/airfoil_io.jl @@ -14,13 +14,13 @@ function write_dat(filepath::String, name::String, x::Vector, y::Vector) end """ - write_polar_csv(filepath, result::NeuralFoilResult) + write_polar(filepath, result::NeuralFoilResult) Write a NeuralFoil result to a `POLAR_VECTORS` table (`alpha, Cd, Cs, Cl, Cm`), CSV or Arrow as the suffix of `filepath` says (see [`write_node_rows`](@ref VortexStepMethod.write_node_rows)). """ -function write_polar_csv(filepath::String, result::NeuralFoilResult) +function write_polar(filepath::String, result::NeuralFoilResult) values = [result.CD zero(result.CD) result.CL result.CM] return write_node_rows(filepath, deg2rad.(result.alpha), nothing, values; columns=["Cd", "Cs", "Cl", "Cm"]) @@ -31,14 +31,14 @@ end solver=NeuralFoilSolver(), delta_range=nothing, crease_frac=0.75, dat_prefix=nothing) -Sweep `solver` over the airfoil coordinates `(x, y)` and write the polar CSV. XFoil -uses the coordinates directly; NeuralFoil fits [`LeastSquaresFit`](@ref) Kulfan -parameters ([`deform_section`](@ref)). Wrap a raw or open single-membrane slice with +Sweep `solver` over the airfoil coordinates `(x, y)` and write the polar table, CSV or +Arrow as the suffix of `output_path` says. XFoil uses the coordinates directly; +NeuralFoil fits [`LeastSquaresFit`](@ref) Kulfan parameters ([`deform_section`](@ref)). Wrap a raw or open single-membrane slice with [`shrink_wrap`](@ref) before calling this. Pass a [`NeuralFoilSolver`](@ref) or [`XFoilSolver`](@ref) to pick the backend. With `delta_range === nothing` the sweep is -over `alpha_range` only and written as a `POLAR_VECTORS` CSV (returns the +over `alpha_range` only and written as a `POLAR_VECTORS` table (returns the `Vector{SectionSolution}`); pass a `delta_range` of trailing-edge deflections to sweep -`(alpha, delta)` and write a long-format `POLAR_MATRICES` CSV (returns the `(cl, cd, +`(alpha, delta)` and write a long-format `POLAR_MATRICES` table (returns the `(cl, cd, cm)` matrices). Both angle ranges are in degrees. `crease_frac` is the chordwise hinge location (0–1) about which each `delta_range` deflection pivots. With `dat_prefix` set, each deflected shape is also written to `{dat_prefix}_{delta_suffix(δ)}.dat`. @@ -52,7 +52,7 @@ function generate_polar_from_coordinates(x::Vector, y::Vector, output_path::Stri if delta_range === nothing def = deform_section(x, y, 0.0) sols = analyze_sweep(solver, def, alphas, Re) - write_polar_csv(output_path, sols) + write_polar(output_path, sols) return sols end deltas = deg2rad.(collect(Float64, delta_range)) @@ -61,33 +61,35 @@ function generate_polar_from_coordinates(x::Vector, y::Vector, output_path::Stri "deflection", xd, yd) cl, cd, cm = generate_aero_matrices(solver, x, y; alpha_range=alphas, delta_range=deltas, Re, crease_frac, on_deform) - write_polar_matrix_csv(output_path, alphas, deltas, cl, cd, cm) + write_polar_matrix(output_path, alphas, deltas, cl, cd, cm) return (cl, cd, cm) end """ - resolve_airfoil(type, info, out_dir, id; Re, alpha_range) -> (new_type, new_info) + resolve_airfoil(type, info, out_dir, id; Re, alpha_range, table_format=:csv) + -> (new_type, new_info) Resolve one awesIO `wing_airfoils` entry to a core-loadable form. `breukels_regression` `(t, kappa)` → `poly` coeffs (via [`lei_poly_coeffs`](@ref)); `neuralfoil` -`(dat_file_path, …)` → a `polars` CSV (via [`generate_polar_from_dat`](@ref)) written -under `out_dir`; `polars`/`poly`/`inviscid` pass through. `masure_regression` is not -yet supported. `info` file paths should already be absolute. +`(dat_file_path, …)` → a `polars` table `{id}.{table_format}` (`:csv` or `:arrow`, via +[`generate_polar_from_dat`](@ref)) written under `out_dir`; `polars`/`poly`/`inviscid` +pass through. `masure_regression` is not yet supported. `info` file paths should +already be absolute. """ function resolve_airfoil(type::AbstractString, info::AbstractDict, out_dir, id; - Re, alpha_range) + Re, alpha_range, table_format::Symbol=:csv) if type == "breukels_regression" cl, cd, cm = lei_poly_coeffs(Float64(info["t"]), Float64(info["kappa"])) return "poly", Dict{String,Any}("cl_coeffs" => cl, "cd_coeffs" => cd, "cm_coeffs" => cm) elseif type == "neuralfoil" - csv = joinpath(out_dir, "$(id).csv") + polar = joinpath(out_dir, "$(id).$(table_format)") solver = NeuralFoilSolver( model_size=String(get(info, "model_size", "large")), n_crit=Float64(get(info, "n_crit", 9.0))) - generate_polar_from_dat(String(info["dat_file_path"]), csv; Re, + generate_polar_from_dat(String(info["dat_file_path"]), polar; Re, alpha_range=collect(alpha_range), solver) - return "polars", Dict{String,Any}("csv_file_path" => csv) + return "polars", Dict{String,Any}("polar_file_path" => polar) elseif type in ("polars", "poly", "inviscid") return String(type), info elseif type == "masure_regression" @@ -100,9 +102,8 @@ end """ generate_polar_from_dat(dat_path, output_path; Re, kwargs...) -Read a `.dat` airfoil and generate its polar CSV via -`generate_polar_from_coordinates`. This is AirfoilAero's `.dat` → CSV entry -point. +Read a `.dat` airfoil and generate its polar table via +`generate_polar_from_coordinates`. This is AirfoilAero's `.dat` → polar entry point. """ function generate_polar_from_dat(dat_path::String, output_path::String; Re::Real, kwargs...) diff --git a/src/airfoil_aero/airfoil_solvers/common.jl b/src/airfoil_aero/airfoil_solvers/common.jl index f24b340c..dc047053 100644 --- a/src/airfoil_aero/airfoil_solvers/common.jl +++ b/src/airfoil_aero/airfoil_solvers/common.jl @@ -32,14 +32,14 @@ struct SectionSolution end """ - write_polar_csv(filepath, sols::Vector{SectionSolution}) + write_polar(filepath, sols::Vector{SectionSolution}) Write a solver sweep (from any [`AbstractAirfoilSolver`](@ref)) to a `POLAR_VECTORS` table (`alpha, Cd, Cs, Cl, Cm`; alpha in degrees), CSV or Arrow as the suffix of `filepath` says. Non-converged angles (`NaN`) are skipped, so this works for both NeuralFoil and XFoil sweeps. """ -function write_polar_csv(filepath::String, sols::Vector{SectionSolution}) +function write_polar(filepath::String, sols::Vector{SectionSolution}) converged = filter(sol -> !isnan(sol.cl), sols) alpha = [sol.alpha for sol in converged] cd = [sol.cd for sol in converged] @@ -50,7 +50,7 @@ function write_polar_csv(filepath::String, sols::Vector{SectionSolution}) end """ - write_polar_matrix_csv(filepath, alpha_range, delta_range, cl, cd, cm) + write_polar_matrix(filepath, alpha_range, delta_range, cl, cd, cm) Write an `(alpha × delta)` sweep to a long-format `POLAR_MATRICES` table with columns `alpha, delta, Cl, Cd, Cm` (both angles in degrees), one row per grid point, CSV or @@ -58,7 +58,7 @@ Arrow as the suffix of `filepath` says. The `delta` column is what marks the fil matrix polar to the loader. `alpha_range` and `delta_range` are in radians; `cl`/`cd`/`cm` are `length(alpha) × length(delta)`. """ -function write_polar_matrix_csv(filepath::String, alpha_range, delta_range, +function write_polar_matrix(filepath::String, alpha_range, delta_range, cl::AbstractMatrix, cd::AbstractMatrix, cm::AbstractMatrix) alpha = repeat(collect(alpha_range), length(delta_range)) delta = repeat(collect(delta_range); inner=length(alpha_range)) diff --git a/src/airfoil_aero/geometry_gen.jl b/src/airfoil_aero/geometry_gen.jl index 9881df82..c682de15 100644 --- a/src/airfoil_aero/geometry_gen.jl +++ b/src/airfoil_aero/geometry_gen.jl @@ -50,11 +50,11 @@ function generate_airfoils(airfoils, output_dir::String; clvals = collect(sol.cl for sol in sols[1]) all(isnan, clvals) && error("solver produced no converged points") if isnothing(delta_range) - write_polar_csv(joinpath(output_dir, polar_rel), sols[1]) + write_polar(joinpath(output_dir, polar_rel), sols[1]) else coeff(f) = [f(sols[jd][ia]) for ia in eachindex(alphas), jd in eachindex(deltas)] - write_polar_matrix_csv(joinpath(output_dir, polar_rel), alphas, deltas, + write_polar_matrix(joinpath(output_dir, polar_rel), alphas, deltas, coeff(s -> s.cl), coeff(s -> s.cd), coeff(s -> s.cm)) end paths = write_section_aero(joinpath(output_dir, "airfoils", "$j"), aero; @@ -64,7 +64,7 @@ function generate_airfoils(airfoils, output_dir::String; af.x_raw, af.y_raw) push!(airfoil_rows, Any[j, "polar_vectors", Dict("dat_file" => dat_rel, "raw_dat_file" => raw_rel, - "csv_file_path" => polar_rel, "cp_file" => cp_rel, + "polar_file_path" => polar_rel, "cp_file" => cp_rel, "cf_file" => cf_rel)]) push!(ok, j) finite_cl = [v for v in clvals if !isnan(v)] diff --git a/src/airfoil_aero/geometry_yaml.jl b/src/airfoil_aero/geometry_yaml.jl index 731627dd..fa6299b7 100644 --- a/src/airfoil_aero/geometry_yaml.jl +++ b/src/airfoil_aero/geometry_yaml.jl @@ -4,7 +4,7 @@ Write a geometry YAML via [`write_yaml`](@ref), one line per section/airfoil row. `section_rows` are `[airfoil_id, LE_x, LE_y, LE_z, TE_x, TE_y, TE_z]`; `airfoil_rows` are `[airfoil_id, type, info_dict]` where `info_dict` holds `dat_file`, -`csv_file_path`, and optionally `raw_dat_file`, `cp_file`, `cf_file`. +`polar_file_path`, and optionally `raw_dat_file`, `cp_file`, `cf_file`. """ function write_geometry_yaml(path::String, section_rows, airfoil_rows) data = Dict( diff --git a/src/obj_adapter/obj_to_yaml.jl b/src/obj_adapter/obj_to_yaml.jl index 8e68d80d..be60252d 100644 --- a/src/obj_adapter/obj_to_yaml.jl +++ b/src/obj_adapter/obj_to_yaml.jl @@ -105,7 +105,8 @@ function obj_to_yaml end migrate_node_tables(yaml_path, output_dir, table_format; verbose=true) Rewrite a generated dataset's per-node `Cp`/`cf` tables and polars in `table_format` -and point `geometry.yaml` at them, when they are not in that format already. This is +and point `geometry.yaml` at them, when they are not in that format already; a legacy +`csv_file_path` key is written back as `polar_file_path`. This is what lets an existing directory change format without re-running the airfoil solver that produced it. The source tables are left in place. @@ -126,7 +127,9 @@ function migrate_node_tables(yaml_path::String, output_dir::String, for row in airfoils["data"] info = row[info_col] info isa AbstractDict || continue - for key in ("cp_file", "cf_file", "csv_file_path") + haskey(info, "csv_file_path") && + (info["polar_file_path"] = pop!(info, "csv_file_path")) + for key in ("cp_file", "cf_file", "polar_file_path") haskey(info, key) || continue relative = String(info[key]) endswith(relative, ".$table_format") && continue @@ -177,7 +180,7 @@ function prefix_table_paths!(airfoil_rows, prefix::String) info = row[end] info isa AbstractDict || continue for (key, value) in info - (endswith(String(key), "_file") || key == "csv_file_path") || continue + (endswith(String(key), "_file") || key == "polar_file_path") || continue value isa AbstractString && !isabspath(value) && (info[key] = joinpath(prefix, value)) end @@ -261,15 +264,17 @@ function obj_to_yaml(obj_path::String, output_dir::String; end """ - resolve_aero_geometry(yaml_in, out_dir; verbose=true) -> yaml_out + resolve_aero_geometry(yaml_in, out_dir; table_format=:csv, verbose=true) -> yaml_out Read an awesIO-style geometry YAML and resolve every `wing_airfoils` entry to a core-loadable form via [`resolve_airfoil`](@ref) (`breukels_regression` → `poly`, -`neuralfoil` → `polars` CSV, others pass through), writing generated CSVs and a -resolved `geometry.yaml` under `out_dir`. `wing_sections` (incl. any `VUP` up-vectors) -pass through unchanged. Load the result with `Wing(yaml_out)`. +`neuralfoil` → `polars` table, others pass through). The generated polars go to +`out_dir/polars` as `table_format` (`:csv` or `:arrow`), the resolved YAML to +`out_dir/geometry.yaml`. `wing_sections` (incl. any `VUP` up-vectors) pass through +unchanged. Load the result with `Wing(yaml_out)`. """ -function resolve_aero_geometry(yaml_in::String, out_dir::String; verbose=true) +function resolve_aero_geometry(yaml_in::String, out_dir::String; + table_format::Symbol=:csv, verbose=true) mkpath(out_dir) polar_dir = joinpath(out_dir, "polars") mkpath(polar_dir) @@ -284,14 +289,15 @@ function resolve_aero_geometry(yaml_in::String, out_dir::String; verbose=true) base = dirname(abspath(yaml_in)) for row in wa["data"] info = Dict{String,Any}(row[ii]) - for k in ("dat_file_path", "csv_file_path", "cl_file_path", + for k in ("dat_file_path", "polar_file_path", "csv_file_path", "cl_file_path", "cd_file_path", "cm_file_path") if haskey(info, k) && !isabspath(String(info[k])) info[k] = abspath(joinpath(base, String(info[k]))) end end new_type, new_info = resolve_airfoil(String(row[ti]), info, polar_dir, - row[idi]; Re, alpha_range) + row[idi]; Re, alpha_range, + table_format) row[ti] = new_type row[ii] = new_info end diff --git a/src/yaml_geometry.jl b/src/yaml_geometry.jl index 043420cb..04d4f7ec 100644 --- a/src/yaml_geometry.jl +++ b/src/yaml_geometry.jl @@ -1,6 +1,6 @@ # Data structures for YAML wing geometry @with_kw struct WingAirfoilInfo - csv_file_path::String + polar_file_path::String dat_file::String = "" cp_file::String = "" cf_file::String = "" @@ -184,23 +184,23 @@ function Wing( airfoil_id = airfoil_dict["airfoil_id"], type = airfoil_dict["type"], info_dict = WingAirfoilInfo( - csv_file_path = get(airfoil_dict["info_dict"], "csv_file_path", ""), - dat_file = get(airfoil_dict["info_dict"], "dat_file", ""), - cp_file = get(airfoil_dict["info_dict"], "cp_file", ""), - cf_file = get(airfoil_dict["info_dict"], "cf_file", ""), - cl_file_path = get(airfoil_dict["info_dict"], "cl_file_path", ""), - cd_file_path = get(airfoil_dict["info_dict"], "cd_file_path", ""), - cm_file_path = get(airfoil_dict["info_dict"], "cm_file_path", "")) + polar_file_path = get(info, "polar_file_path", + get(info, "csv_file_path", "")), + dat_file = get(info, "dat_file", ""), + cp_file = get(info, "cp_file", ""), + cf_file = get(info, "cf_file", ""), + cl_file_path = get(info, "cl_file_path", ""), + cd_file_path = get(info, "cd_file_path", ""), + cm_file_path = get(info, "cm_file_path", "")) )) end - # Create CSV file mapping from airfoils - airfoil_csv_map = Dict{Int64, String}() + airfoil_polar_map = Dict{Int64, String}() airfoil_surface_map = Dict{Int64, NTuple{3, String}}() airfoil_matrix_map = Dict{Int64, NTuple{3, String}}() for airfoil in airfoils - if !isempty(airfoil.info_dict.csv_file_path) - airfoil_csv_map[airfoil.airfoil_id] = airfoil.info_dict.csv_file_path + if !isempty(airfoil.info_dict.polar_file_path) + airfoil_polar_map[airfoil.airfoil_id] = airfoil.info_dict.polar_file_path end if !isempty(airfoil.info_dict.cp_file) && !isempty(airfoil.info_dict.cf_file) airfoil_surface_map[airfoil.airfoil_id] = (airfoil.info_dict.dat_file, @@ -245,8 +245,8 @@ function Wing( "type \"$airfoil_type\"; resolve it with AirfoilAero " * "(resolve_aero_geometry) into polars/poly before loading.")) else - csv_file_path = resolve(get(airfoil_csv_map, section.airfoil_id, "")) - aero_data, aero_model = load_polar_data(csv_file_path) + polar_path = resolve(get(airfoil_polar_map, section.airfoil_id, "")) + aero_data, aero_model = load_polar_data(polar_path) end surface = get(airfoil_surface_map, section.airfoil_id, nothing) diff --git a/test/airfoil_aero/test_airfoil_aero.jl b/test/airfoil_aero/test_airfoil_aero.jl index 9bea4be0..c91fe5d0 100644 --- a/test/airfoil_aero/test_airfoil_aero.jl +++ b/test/airfoil_aero/test_airfoil_aero.jl @@ -294,7 +294,7 @@ end cm = [0.06 - 0.01 * d for a in -5:5:15, d in -3:3:3] work = mktempdir() - matrix_csv = write_polar_matrix_csv(joinpath(work, "matrix.csv"), alpha_range, + matrix_csv = write_polar_matrix(joinpath(work, "matrix.csv"), alpha_range, delta_range, cl, cd, cm) (alphas, deltas, cl_back, cd_back, cm_back), model = load_polar_data(matrix_csv) @test model == VortexStepMethod.POLAR_MATRICES @@ -304,14 +304,14 @@ end sols = [SectionSolution(alpha_range[i], cl[i, 1], cd[i, 1], cm[i, 1], 1.0, Float64[], Float64[], Float64[], Float64[]) for i in eachindex(alpha_range)] - vectors_csv = write_polar_csv(joinpath(work, "vectors.csv"), sols) + vectors_csv = write_polar(joinpath(work, "vectors.csv"), sols) (alphas, _, cd_vec, _), _ = load_polar_data(vectors_csv) @test alphas == alpha_range @test cd_vec ≈ cd[:, 1] result = AirfoilAero.NeuralFoilResult(collect(-5.0:5:15), cl[:, 1], cd[:, 1], cm[:, 1], ones(5)) - neuralfoil_csv = write_polar_csv(joinpath(work, "neuralfoil.csv"), result) + neuralfoil_csv = write_polar(joinpath(work, "neuralfoil.csv"), result) (alphas, _, cd_vec, _), _ = load_polar_data(neuralfoil_csv) @test alphas == alpha_range @test cd_vec ≈ cd[:, 1] @@ -333,14 +333,14 @@ end result = AirfoilAero.NeuralFoilResult(collect(-5.0:5:15), cl[:, 1], cd[:, 1], cm[:, 1], ones(5)) writers = Dict( - "matrix" => (path -> write_polar_matrix_csv(path, alpha_range, delta_range, + "matrix" => (path -> write_polar_matrix(path, alpha_range, delta_range, cl, cd, cm), ["Cl", "Cd", "Cm"]), - "vectors" => (path -> write_polar_csv(path, sols), ["Cd", "Cs", "Cl", "Cm"]), - "neuralfoil" => (path -> write_polar_csv(path, result), ["Cd", "Cs", "Cl", "Cm"])) + "vectors" => (path -> write_polar(path, sols), ["Cd", "Cs", "Cl", "Cm"]), + "neuralfoil" => (path -> write_polar(path, result), ["Cd", "Cs", "Cl", "Cm"])) work = mktempdir() - for (name, (write_polar, expected_columns)) in writers - csv = write_polar(joinpath(work, "$name.csv")) - arrow = write_polar(joinpath(work, "$name.arrow")) + for (name, (write_table, expected_columns)) in writers + csv = write_table(joinpath(work, "$name.csv")) + arrow = write_table(joinpath(work, "$name.arrow")) table = VortexStepMethod.Arrow.Table(read(arrow)) @test VortexStepMethod.Arrow.getmetadata(table)["columns"] == join(expected_columns, ",") diff --git a/test/body_aerodynamics/test_wing.yaml b/test/body_aerodynamics/test_wing.yaml index 964fe9ec..8ac45184 100644 --- a/test/body_aerodynamics/test_wing.yaml +++ b/test/body_aerodynamics/test_wing.yaml @@ -9,4 +9,4 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "simple_polar.csv"}] + - [1, polars, {polar_file_path: "simple_polar.csv"}] diff --git a/test/data/solver/wings/solver_test_wing.yaml b/test/data/solver/wings/solver_test_wing.yaml index 14969bce..c15b59a1 100644 --- a/test/data/solver/wings/solver_test_wing.yaml +++ b/test/data/solver/wings/solver_test_wing.yaml @@ -9,4 +9,4 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: ""}] + - [1, polars, {polar_file_path: ""}] diff --git a/test/obj_adapter/test_obj_adapter.jl b/test/obj_adapter/test_obj_adapter.jl index 0d15005c..e04188e6 100644 --- a/test/obj_adapter/test_obj_adapter.jl +++ b/test/obj_adapter/test_obj_adapter.jl @@ -54,7 +54,8 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", verbose=false) @test isfile(yaml) info = Dict(YAML.load_file(yaml)["wing_airfoils"]["data"][1][3]) - @test all(haskey(info, k) for k in ("dat_file", "csv_file_path", "cp_file", "cf_file")) + @test all(haskey(info, k) + for k in ("dat_file", "polar_file_path", "cp_file", "cf_file")) @test isfile(joinpath(outdir, info["dat_file"])) @test isfile(joinpath(outdir, info["cp_file"])) @test isfile(joinpath(outdir, info["cf_file"])) @@ -68,17 +69,20 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", alpha_range=-4:2:4, aero_solver=NeuralFoilSolver(model_size="medium"), verbose=false) csv_info = Dict(YAML.load_file(csv_yaml)["wing_airfoils"]["data"][1][3]) + write(csv_yaml, replace(read(csv_yaml, String), + "polar_file_path" => "csv_file_path")) arrow_yaml = obj_to_yaml(obj_path, outdir; n_sections=3, Re=5e5, verbose=false, table_format=:arrow) info = Dict(YAML.load_file(arrow_yaml)["wing_airfoils"]["data"][1][3]) - for key in ("cp_file", "cf_file", "csv_file_path") + @test !haskey(info, "csv_file_path") + for key in ("cp_file", "cf_file", "polar_file_path") @test endswith(info[key], ".arrow") @test isfile(joinpath(outdir, info[key])) @test isfile(joinpath(outdir, csv_info[key])) end - @test isequal(load_polar_data(joinpath(outdir, info["csv_file_path"])), - load_polar_data(joinpath(outdir, csv_info["csv_file_path"]))) + @test isequal(load_polar_data(joinpath(outdir, info["polar_file_path"])), + load_polar_data(joinpath(outdir, csv_info["polar_file_path"]))) @test Wing(arrow_yaml; n_panels=4) isa Wing # already in that format: nothing to convert, YAML untouched @@ -104,7 +108,7 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", @test !isfile(joinpath(tables, "geometry.yaml")) info = Dict(YAML.load_file(yaml_path)["wing_airfoils"]["data"][1][3]) - for key in ("csv_file_path", "dat_file") + for key in ("polar_file_path", "dat_file") @test startswith(info[key], "tables/") @test isfile(joinpath(dirname(yaml_path), info[key])) end @@ -116,6 +120,29 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", tables) == "" end + @testset "resolve_aero_geometry writes neuralfoil polars as table_format" begin + dir = mktempdir() + cp(joinpath(@__DIR__, "..", "airfoil_aero", "data", "test_airfoil.dat"), + joinpath(dir, "airfoil.dat")) + yaml_in = joinpath(dir, "awesio.yaml") + write_yaml(yaml_in, Dict( + "wing_sections" => Dict( + "headers" => ["airfoil_id", "LE_x", "LE_y", "LE_z", "TE_x", "TE_y", "TE_z"], + "data" => [Any[1, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0], + Any[1, 0.0, -1.0, 0.0, 1.0, -1.0, 0.0]]), + "wing_airfoils" => Dict( + "alpha_range" => [-4, 4, 2], "reynolds" => 5e5, + "headers" => ["airfoil_id", "type", "info_dict"], + "data" => [Any[1, "neuralfoil", Dict("dat_file_path" => "airfoil.dat", + "model_size" => "medium")]]))) + yaml_out = resolve_aero_geometry(yaml_in, joinpath(dir, "out"); + table_format=:arrow, verbose=false) + info = YAML.load_file(yaml_out)["wing_airfoils"]["data"][1][3] + @test endswith(info["polar_file_path"], "1.arrow") + @test isfile(info["polar_file_path"]) + @test Wing(yaml_out; n_panels=2).unrefined_sections[1].aero_model == POLAR_VECTORS + end + @testset "write_yaml emits nested and scalar values" begin dir = mktempdir() nested = joinpath(dir, "nested.yaml") diff --git a/test/solver/solver_test_wing.yaml b/test/solver/solver_test_wing.yaml index fb2d9328..3c8cc0d2 100644 --- a/test/solver/solver_test_wing.yaml +++ b/test/solver/solver_test_wing.yaml @@ -9,4 +9,4 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "solver_test_polar.csv"}] + - [1, polars, {polar_file_path: "solver_test_polar.csv"}] diff --git a/test/surfplan/test_surfplan.jl b/test/surfplan/test_surfplan.jl index c906c18d..c1698c13 100644 --- a/test/surfplan/test_surfplan.jl +++ b/test/surfplan/test_surfplan.jl @@ -42,7 +42,7 @@ import YAML table_format=:arrow) info = Dict(YAML.load_file(reused)["wing_airfoils"]["data"][1][3]) @test all(endswith(info[key], ".arrow") for key in ("cp_file", "cf_file", - "csv_file_path")) + "polar_file_path")) @test Wing(reused) isa Wing arrow_dir = mktempdir() diff --git a/test/yaml_geometry/complex_wing.yaml b/test/yaml_geometry/complex_wing.yaml index ff38435d..ffee6887 100644 --- a/test/yaml_geometry/complex_wing.yaml +++ b/test/yaml_geometry/complex_wing.yaml @@ -14,5 +14,5 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "standard_airfoil.csv"}] - - [2, polars, {csv_file_path: "alternate_airfoil.csv"}] + - [1, polars, {polar_file_path: "standard_airfoil.csv"}] + - [2, polars, {polar_file_path: "alternate_airfoil.csv"}] diff --git a/test/yaml_geometry/simple_wing.yaml b/test/yaml_geometry/simple_wing.yaml index e6c62baa..1dd7c90b 100644 --- a/test/yaml_geometry/simple_wing.yaml +++ b/test/yaml_geometry/simple_wing.yaml @@ -9,4 +9,4 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "standard_airfoil.csv"}] + - [1, polars, {polar_file_path: "standard_airfoil.csv"}] diff --git a/test/yaml_geometry/test_wing_constructor.jl b/test/yaml_geometry/test_wing_constructor.jl index 3ad6f944..4436e747 100644 --- a/test/yaml_geometry/test_wing_constructor.jl +++ b/test/yaml_geometry/test_wing_constructor.jl @@ -81,7 +81,7 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "nonexistent.csv"}] + - [1, polars, {polar_file_path: "nonexistent.csv"}] """ write(test_yaml_path, yaml_content) @@ -106,7 +106,7 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: ""}] + - [1, polars, {polar_file_path: ""}] """ write(test_yaml_path, yaml_content) @@ -129,7 +129,7 @@ wing_airfoils: reynolds: 1000000 headers: [airfoil_id, type, info_dict] data: - - [1, polars, {csv_file_path: "polars/1.csv"}] + - [1, polars, {polar_file_path: "polars/1.csv"}] """ write(test_yaml_path, yaml_content) @@ -141,6 +141,18 @@ wing_airfoils: @test_throws ArgumentError Wing(test_yaml_path; spanwise_direction=[1.0, 0.0, 0.0]) end + @testset "The legacy csv_file_path key still names the polar" begin + legacy = replace(read(test_data_path("yaml_geometry", "simple_wing.yaml"), String), + "polar_file_path" => "csv_file_path") + legacy_path = joinpath(work_dir, "legacy_wing.yaml") + write(legacy_path, legacy) + cp(test_data_path("yaml_geometry", "standard_airfoil.csv"), + joinpath(work_dir, "standard_airfoil.csv"); force=true) + wing = Wing(legacy_path; n_panels=2) + @test wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[1].aero_data isa Tuple + end + @testset "Relative Path Resolution" begin # Test that relative paths in CSV files are resolved relative to YAML file subdir = joinpath(work_dir, "subtest") From 63147b7b28f680dd009b21d6096d04661f090e50 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:53:26 +0200 Subject: [PATCH 4/4] Wrap the generate_polar_from_coordinates docstring at 92 Co-Authored-By: Claude Opus 5 --- src/airfoil_aero/airfoil_io.jl | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/airfoil_aero/airfoil_io.jl b/src/airfoil_aero/airfoil_io.jl index d23a2fc7..91d0c6ac 100644 --- a/src/airfoil_aero/airfoil_io.jl +++ b/src/airfoil_aero/airfoil_io.jl @@ -33,15 +33,16 @@ end Sweep `solver` over the airfoil coordinates `(x, y)` and write the polar table, CSV or Arrow as the suffix of `output_path` says. XFoil uses the coordinates directly; -NeuralFoil fits [`LeastSquaresFit`](@ref) Kulfan parameters ([`deform_section`](@ref)). Wrap a raw or open single-membrane slice with -[`shrink_wrap`](@ref) before calling this. Pass a [`NeuralFoilSolver`](@ref) or -[`XFoilSolver`](@ref) to pick the backend. With `delta_range === nothing` the sweep is -over `alpha_range` only and written as a `POLAR_VECTORS` table (returns the -`Vector{SectionSolution}`); pass a `delta_range` of trailing-edge deflections to sweep -`(alpha, delta)` and write a long-format `POLAR_MATRICES` table (returns the `(cl, cd, -cm)` matrices). Both angle ranges are in degrees. `crease_frac` is the chordwise hinge -location (0–1) about which each `delta_range` deflection pivots. With `dat_prefix` set, -each deflected shape is also written to `{dat_prefix}_{delta_suffix(δ)}.dat`. +NeuralFoil fits [`LeastSquaresFit`](@ref) Kulfan parameters ([`deform_section`](@ref)). +Wrap a raw or open single-membrane slice with [`shrink_wrap`](@ref) before calling this. +Pass a [`NeuralFoilSolver`](@ref) or [`XFoilSolver`](@ref) to pick the backend. With +`delta_range === nothing` the sweep is over `alpha_range` only and written as a +`POLAR_VECTORS` table (returns the `Vector{SectionSolution}`); pass a `delta_range` of +trailing-edge deflections to sweep `(alpha, delta)` and write a long-format +`POLAR_MATRICES` table (returns the `(cl, cd, cm)` matrices). Both angle ranges are in +degrees. `crease_frac` is the chordwise hinge location (0–1) about which each +`delta_range` deflection pivots. With `dat_prefix` set, each deflected shape is also +written to `{dat_prefix}_{delta_suffix(δ)}.dat`. """ function generate_polar_from_coordinates(x::Vector, y::Vector, output_path::String; Re::Real, alpha_range=-180:1:180,