From 2db78cdb9ec822508ec81259a936ef2fb0e1a064 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:23:25 +0200 Subject: [PATCH 1/3] Resolve masure_regression airfoils with a pure-Julia Extra-Trees evaluator Co-Authored-By: Claude Opus 5.5 --- data/TUDELFT_V3_KITE/aero_geometry.yaml | 2 +- .../aero_geometry_coarse_discretisation.yaml | 4 +- docs/src/airfoil_pipeline.md | 15 +++ docs/src/functions.md | 2 + docs/src/private_functions.md | 7 + docs/src/private_types.md | 2 + scripts/export_masure_models.py | 125 ++++++++++++++++++ src/airfoil_aero/AirfoilAero.jl | 2 + src/airfoil_aero/airfoil_io.jl | 37 +++--- src/airfoil_aero/airfoil_solvers/common.jl | 25 ++-- src/airfoil_aero/masure_regression.jl | 116 ++++++++++++++++ src/obj_adapter/obj_to_yaml.jl | 11 +- test/airfoil_aero/data/masure/ET_re1e6.npz | Bin 0 -> 47626 bytes test/airfoil_aero/data/masure/reference.npz | Bin 0 -> 2810 bytes test/airfoil_aero/test_masure_regression.jl | 54 ++++++++ test/runtests.jl | 1 + 16 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 scripts/export_masure_models.py create mode 100644 src/airfoil_aero/masure_regression.jl create mode 100644 test/airfoil_aero/data/masure/ET_re1e6.npz create mode 100644 test/airfoil_aero/data/masure/reference.npz create mode 100644 test/airfoil_aero/test_masure_regression.jl diff --git a/data/TUDELFT_V3_KITE/aero_geometry.yaml b/data/TUDELFT_V3_KITE/aero_geometry.yaml index 6918cd72..a136a2f7 100644 --- a/data/TUDELFT_V3_KITE/aero_geometry.yaml +++ b/data/TUDELFT_V3_KITE/aero_geometry.yaml @@ -75,7 +75,7 @@ wing_airfoils: # - polars: # polar_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) # - masure_regression: - # t, eta, kappa, delta, lamba, phi: Regression parameters + # t, eta, kappa, delta, lambda, phi: Regression parameters # - inviscid: # no further data is required # --------------------------------------------------------------- diff --git a/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml b/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml index f122cfbb..a1274956 100644 --- a/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml +++ b/data/TUDELFT_V3_KITE/aero_geometry_coarse_discretisation.yaml @@ -120,7 +120,7 @@ wing_airfoils: # --------------------------------------------------------------- # headers: # - airfoil_id: integer, unique identifier for the airfoil - # - type: one of [neuralfoil, breukels_regression, measure_regression, polars] + # - type: one of [neuralfoil, breukels_regression, masure_regression, polars] # - info_dict: dictionary with parameters depending on 'type' # # info_dict fields by type: @@ -141,7 +141,7 @@ wing_airfoils: # Dirty wind tunnel: 4–8 # - polars: # polar_file_path: Path to polar CSV file (columns: alpha [rad], cl, cd, cm) - # - measure_regression: + # - masure_regression: # t, eta, kappa, delta, lambda, phi: Regression parameters # - inviscid: # no further data is required diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index edc5d932..d38c6567 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -124,6 +124,21 @@ keeping its own edge positions, and a warning lists the reuse. All floats are ro precision by the single [`write_yaml`](@ref VortexStepMethod.ObjAdapter.write_yaml) writer, so generated geometry files stay diff-friendly and consistent. +## Parametric LEI airfoils: the masure regression + +A geometry YAML can also describe a leading-edge-inflatable airfoil by six shape +parameters instead of a slice, as the `wing_airfoils` entry +`[id, masure_regression, {t, eta, kappa, delta, lambda, phi}]`: tube diameter, chordwise +and vertical position of maximum camber, trailing-edge reflex angle [deg], camber tension +and leading-edge tension. [`resolve_aero_geometry`](@ref VortexStepMethod.ObjAdapter.resolve_aero_geometry) +turns it into a `POLAR_VECTORS` table over the block's `alpha_range` with +[`masure_aero`](@ref), which evaluates the Extra-Trees regression of K.R.G. Masure, +trained on 2D RANS simulations, in pure Julia. The models exist for `reynolds` 1e6, 5e6 +and 2e7. Download them from [Zenodo](https://doi.org/10.5281/zenodo.16925758), convert +them once with `python scripts/export_masure_models.py ET_re*.pkl --out DIR` (needs numpy +and scikit-learn), and pass `ml_models_dir=DIR`. The converted model gives the same +coefficients as scikit-learn's `predict`. + ## Why this is useful - **CAD in, solver-ready model out.** No hand-drawing airfoils or manually pairing them diff --git a/docs/src/functions.md b/docs/src/functions.md index 8f934a66..a1913716 100644 --- a/docs/src/functions.md +++ b/docs/src/functions.md @@ -41,6 +41,8 @@ deform_section analyze_section analyze_sweep neuralfoil_aero +masure_aero +load_masure_model deform_kulfan chord_residual chord_line diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index d8b0c836..f6da3f59 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -228,6 +228,13 @@ swish sigmoid ``` +### Masure regression +```@docs +MASURE_PARAMETERS +MASURE_REYNOLDS +predict +``` + ### Polars and airfoil IO ```@docs create_2d_polars diff --git a/docs/src/private_types.md b/docs/src/private_types.md index 0941c427..4072b640 100644 --- a/docs/src/private_types.md +++ b/docs/src/private_types.md @@ -30,6 +30,8 @@ KulfanBasis LivePolarSettings LivePolars NeuralFoilModel +MasureModel +ExtraTreesForest NeuralFoilResult NeuralFoilWorkspace ContourPressureScratch diff --git a/scripts/export_masure_models.py b/scripts/export_masure_models.py new file mode 100644 index 00000000..3fb0fdfd --- /dev/null +++ b/scripts/export_masure_models.py @@ -0,0 +1,125 @@ +"""Export the masure-regression scikit-learn models to the .npz files AirfoilAero reads. + + python export_masure_models.py ET_re1e6.pkl ET_re5e6.pkl ET_re2e7.pkl --out DIR + python export_masure_models.py --fixture DIR + +The first form converts the pickles from https://doi.org/10.5281/zenodo.16925758 into +`DIR/ET_re.npz`; pass `DIR` as `ml_models_dir` to `resolve_aero_geometry`. The +second trains a small model of the same layout on synthetic data and writes it with +the inputs and `predict` outputs the Julia test compares against. Needs numpy and +scikit-learn. + +An exported file holds `input_mean` and `input_scale` (the StandardScaler) and, for +output k = 0, 1, 2 (CD, CL, CM), the nodes of all its trees concatenated: +`output{k}_roots`, `_left`, `_right`, `_feature`, `_threshold`, `_value`. Node and +feature indices are 0-based, and `_left` is -1 on a leaf. +""" + +import argparse +import pickle +from pathlib import Path + +import numpy as np +from sklearn.ensemble import ExtraTreesRegressor +from sklearn.multioutput import MultiOutputRegressor +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + + +def forest_arrays(forest): + """Concatenate the nodes of every tree in `forest`, children indexed globally.""" + roots, left, right, feature, threshold, value = [], [], [], [], [], [] + offset = 0 + for estimator in forest.estimators_: + tree = estimator.tree_ + if tree.n_outputs != 1: + raise ValueError(f"expected single-output trees, got {tree.n_outputs}") + is_leaf = tree.children_left == -1 + roots.append(offset) + left.append(np.where(is_leaf, -1, tree.children_left + offset)) + right.append(np.where(is_leaf, -1, tree.children_right + offset)) + feature.append(tree.feature) + threshold.append(tree.threshold) + value.append(tree.value[:, 0, 0]) + offset += tree.node_count + return { + "roots": np.asarray(roots, dtype=np.int32), + "left": np.concatenate(left).astype(np.int32), + "right": np.concatenate(right).astype(np.int32), + "feature": np.concatenate(feature).astype(np.int32), + "threshold": np.concatenate(threshold).astype(np.float64), + "value": np.concatenate(value).astype(np.float64), + } + + +def export_model(model, npz_path): + """Write a StandardScaler -> MultiOutputRegressor(ExtraTreesRegressor) pipeline.""" + steps = [step for _, step in model.steps] if isinstance(model, Pipeline) else [] + if not (len(steps) == 2 and isinstance(steps[0], StandardScaler) + and isinstance(steps[1], MultiOutputRegressor)): + raise ValueError(f"unexpected model layout: {model!r}") + scaler, multi_output = steps + if len(multi_output.estimators_) != 3: + raise ValueError(f"expected 3 outputs, got {len(multi_output.estimators_)}") + arrays = {"input_mean": scaler.mean_, "input_scale": scaler.scale_} + for k, forest in enumerate(multi_output.estimators_): + if not isinstance(forest, ExtraTreesRegressor): + raise ValueError(f"output {k} is a {type(forest).__name__}") + for name, array in forest_arrays(forest).items(): + arrays[f"output{k}_{name}"] = array + np.savez(npz_path, **arrays) + + +def threshold_rows(model, row): + """Copies of `row` moved onto each output's first root split, where float32 matters.""" + scaler, multi_output = model.named_steps["scale"], model.named_steps["model"] + rows = [] + for forest in multi_output.estimators_: + tree = forest.estimators_[0].tree_ + feature, threshold = tree.feature[0], tree.threshold[0] + for step in (-1e-9, 0.0, 1e-9): + moved = row.copy() + moved[feature] = threshold * (1 + step) * scaler.scale_[feature] + \ + scaler.mean_[feature] + rows.append(moved) + return np.array(rows) + + +def write_fixture(out_dir): + """Train a small pipeline of the published layout and write it with its predictions.""" + rng = np.random.default_rng(42) + low = np.array([0.05, 0.1, 0.0, -10.0, 0.1, 0.1, -10.0]) + high = np.array([0.12, 0.6, 0.15, 5.0, 0.4, 0.9, 30.0]) + X = rng.uniform(low, high, size=(400, 7)) + alpha = np.deg2rad(X[:, 6]) + y = np.column_stack([0.02 + 0.3 * alpha**2 + X[:, 0], + 2 * np.pi * alpha + 5 * X[:, 2], + -0.1 - X[:, 2] + 0.01 * X[:, 3]]) + model = Pipeline([ + ("scale", StandardScaler()), + ("model", MultiOutputRegressor(ExtraTreesRegressor( + n_estimators=5, max_depth=6, max_features="log2", random_state=42))), + ]).fit(X, y) + out_dir.mkdir(parents=True, exist_ok=True) + export_model(model, out_dir / "ET_re1e6.npz") + X_test = np.vstack([rng.uniform(low, high, size=(20, 7)), threshold_rows(model, X[0])]) + np.savez(out_dir / "reference.npz", X=X_test, Y=model.predict(X_test)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("pickles", nargs="*", type=Path) + parser.add_argument("--out", type=Path, default=Path(".")) + parser.add_argument("--fixture", type=Path) + args = parser.parse_args() + if args.fixture is not None: + write_fixture(args.fixture) + for pickle_path in args.pickles: + with open(pickle_path, "rb") as file: + model = pickle.load(file) + args.out.mkdir(parents=True, exist_ok=True) + export_model(model, args.out / pickle_path.with_suffix(".npz").name) + + +if __name__ == "__main__": + main() diff --git a/src/airfoil_aero/AirfoilAero.jl b/src/airfoil_aero/AirfoilAero.jl index de24e654..650066f2 100644 --- a/src/airfoil_aero/AirfoilAero.jl +++ b/src/airfoil_aero/AirfoilAero.jl @@ -16,6 +16,7 @@ include("kulfan.jl") include("deform.jl") include("shrink_wrap.jl") include("neuralfoil.jl") +include("masure_regression.jl") include("poly.jl") include("airfoil_solvers/common.jl") include("airfoil_solvers/xfoil_solver.jl") @@ -33,6 +34,7 @@ export ShrinkWrap, shrink_wrap export fit_kulfan_parameters, kulfan_to_coordinates export NeuralFoilModel, NeuralFoilResult, load_neuralfoil_model export neuralfoil_aero, neuralfoil_section +export MasureModel, load_masure_model, masure_aero export KulfanBasis, deform_kulfan, control_point_deflection export chord_residual, chord_line export LivePolarSettings, LivePolars, panel_kulfan_parameters diff --git a/src/airfoil_aero/airfoil_io.jl b/src/airfoil_aero/airfoil_io.jl index b0ee6e2a..fe549d76 100644 --- a/src/airfoil_aero/airfoil_io.jl +++ b/src/airfoil_aero/airfoil_io.jl @@ -16,15 +16,10 @@ end """ 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)). +Write a NeuralFoil result to a `POLAR_VECTORS` table. """ -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"]) -end +write_polar(filepath::String, result::NeuralFoilResult) = + write_polar(filepath, deg2rad.(result.alpha), result.CL, result.CD, result.CM) """ generate_polar_from_coordinates(x, y, output_path; Re, alpha_range=-180:1:180, @@ -69,34 +64,40 @@ function generate_polar_from_coordinates(x::Vector, y::Vector, output_path::Stri end """ - resolve_airfoil(type, info, out_dir, id; Re, alpha_range, table_format=:csv) - -> (new_type, new_info) + resolve_airfoil(type, info, out_dir, id; Re, alpha_range, table_format=:csv, + ml_models_dir=nothing) -> (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` 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. +`(dat_file_path, …)` and `masure_regression` `(t, eta, kappa, delta, lambda, phi)` → a +`polars` table `{id}.{table_format}` (`:csv` or `:arrow`) written under `out_dir`, the +latter from the model in `ml_models_dir` (see [`load_masure_model`](@ref)); +`polars`/`poly`/`inviscid` pass through. `info` file paths should already be absolute. """ function resolve_airfoil(type::AbstractString, info::AbstractDict, out_dir, id; - Re, alpha_range, table_format::Symbol=:csv) + Re, alpha_range, table_format::Symbol=:csv, + ml_models_dir=nothing) + polar = joinpath(out_dir, "$(id).$(table_format)") 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" - 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"]), polar; Re, alpha_range=collect(alpha_range), solver) return "polars", Dict{String,Any}("polar_file_path" => polar) + elseif type == "masure_regression" + ml_models_dir === nothing && + error("masure_regression airfoil $id needs ml_models_dir.") + alpha = collect(Float64, alpha_range) + cl, cd, cm = masure_aero(load_masure_model(Re, ml_models_dir), info, alpha) + write_polar(polar, deg2rad.(alpha), cl, cd, cm) + return "polars", Dict{String,Any}("polar_file_path" => polar) elseif type in ("polars", "poly", "inviscid") return String(type), info - elseif type == "masure_regression" - error("masure_regression airfoils are not yet supported by AirfoilAero.") else error("Unknown airfoil type: $type") end diff --git a/src/airfoil_aero/airfoil_solvers/common.jl b/src/airfoil_aero/airfoil_solvers/common.jl index 3a99ed13..b7fb829e 100644 --- a/src/airfoil_aero/airfoil_solvers/common.jl +++ b/src/airfoil_aero/airfoil_solvers/common.jl @@ -31,22 +31,29 @@ struct SectionSolution cf::Vector{Float64} end +""" + write_polar(filepath, alpha, cl, cd, cm) + +Write a polar to a `POLAR_VECTORS` table (`alpha, Cd, Cs, Cl, Cm`), CSV or Arrow as the +suffix of `filepath` says. `alpha` is in radians and is written in degrees. +""" +function write_polar(filepath::String, alpha, cl, cd, cm) + return write_node_rows(filepath, alpha, nothing, [cd zero(cd) cl cm]; + columns=["Cd", "Cs", "Cl", "Cm"]) +end + """ 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. +table, skipping non-converged angles (`NaN`), so this works for both NeuralFoil and +XFoil sweeps. """ 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] - 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"]) + return write_polar(filepath, [sol.alpha for sol in converged], + [sol.cl for sol in converged], [sol.cd for sol in converged], + [sol.cm for sol in converged]) end """ diff --git a/src/airfoil_aero/masure_regression.jl b/src/airfoil_aero/masure_regression.jl new file mode 100644 index 00000000..56c24c9d --- /dev/null +++ b/src/airfoil_aero/masure_regression.jl @@ -0,0 +1,116 @@ +""" +Masure regression - pure Julia evaluation of the Extra-Trees models that predict the +polars of a parametric leading-edge-inflatable (LEI) airfoil. + +The trained scikit-learn models are https://doi.org/10.5281/zenodo.16925758; +`scripts/export_masure_models.py` converts them to the `.npz` files read here. +""" + +"Airfoil parameters the masure regression takes, in its input order (alpha follows)." +const MASURE_PARAMETERS = ("t", "eta", "kappa", "delta", "lambda", "phi") + +"Reynolds numbers a masure regression model exists for, with their file suffix." +const MASURE_REYNOLDS = Dict(1.0e6 => "1e6", 5.0e6 => "5e6", 2.0e7 => "2e7") + +const _MASURE_CACHE = Dict{Tuple{Float64,String}, Any}() + +""" + ExtraTreesForest + +The regression trees predicting one output, nodes of all trees concatenated. A leaf +has `left == 0`. +""" +struct ExtraTreesForest + "first node of each tree" + roots::Vector{Int32} + "child taken when the feature is at or below the threshold" + left::Vector{Int32} + "child taken when the feature is above the threshold" + right::Vector{Int32} + "input feature a node splits on" + feature::Vector{Int32} + "split threshold, in scaled input units" + threshold::Vector{Float64} + "prediction of a leaf" + value::Vector{Float64} +end + +""" + MasureModel + +A masure regression model for one Reynolds number: the input standardisation and one +[`ExtraTreesForest`](@ref) per output, in the order CD, CL, CM. +""" +struct MasureModel + input_mean::Vector{Float64} + input_scale::Vector{Float64} + forests::Vector{ExtraTreesForest} +end + +function ExtraTreesForest(data::AbstractDict, prefix::String) + array(name) = data["$(prefix)_$(name)"] + return ExtraTreesForest(array("roots") .+ Int32(1), array("left") .+ Int32(1), + array("right") .+ Int32(1), array("feature") .+ Int32(1), + array("threshold"), array("value")) +end + +""" + load_masure_model(Re, ml_models_dir) -> MasureModel + +Load the masure regression model for Reynolds number `Re` (1e6, 5e6 or 2e7) from +`ml_models_dir/ET_re.npz`, as written by `scripts/export_masure_models.py`. +""" +function load_masure_model(Re::Real, ml_models_dir::AbstractString) + haskey(MASURE_REYNOLDS, Re) || error("No masure regression model for Re = $Re; " * + "available: $(join(sort!(collect(keys(MASURE_REYNOLDS))), ", ")).") + path = joinpath(ml_models_dir, "ET_re$(MASURE_REYNOLDS[Re]).npz") + get!(_MASURE_CACHE, (Float64(Re), abspath(path))) do + isfile(path) || error("Masure regression model not found at $path. Download " * + "the models from https://doi.org/10.5281/zenodo.16925758 and convert them " * + "with scripts/export_masure_models.py.") + data = npzread(path) + MasureModel(data["input_mean"], data["input_scale"], + [ExtraTreesForest(data, "output$k") for k in 0:2]) + end::MasureModel +end + +""" + predict(forest::ExtraTreesForest, x) -> Float64 + +Mean of the leaf values the scaled input `x` reaches in each tree. +""" +function predict(forest::ExtraTreesForest, x::AbstractVector{Float32}) + total = 0.0 + for root in forest.roots + node = root + while forest.left[node] != 0 + node = x[forest.feature[node]] <= forest.threshold[node] ? + forest.left[node] : forest.right[node] + end + total += forest.value[node] + end + return total / length(forest.roots) +end + +""" + masure_aero(model::MasureModel, params, alpha) -> (cl, cd, cm) + +Lift, drag and moment coefficients of the LEI airfoil described by `params` (keys +[`MASURE_PARAMETERS`](@ref), `delta` in degrees) at each angle of attack in `alpha` +[deg]. +""" +function masure_aero(model::MasureModel, params::AbstractDict, alpha::AbstractVector) + raw = [Float64(params[name]) for name in MASURE_PARAMETERS] + x = zeros(Float32, length(raw) + 1) + coefficients = zeros(length(alpha), length(model.forests)) + for (i, angle) in enumerate(alpha) + for (j, value) in enumerate((raw..., Float64(angle))) + # sklearn trees compare the scaled input rounded to Float32. + x[j] = Float32((value - model.input_mean[j]) / model.input_scale[j]) + end + for (k, forest) in enumerate(model.forests) + coefficients[i, k] = predict(forest, x) + end + end + return coefficients[:, 2], coefficients[:, 1], coefficients[:, 3] +end diff --git a/src/obj_adapter/obj_to_yaml.jl b/src/obj_adapter/obj_to_yaml.jl index 609e84b6..84d3589e 100644 --- a/src/obj_adapter/obj_to_yaml.jl +++ b/src/obj_adapter/obj_to_yaml.jl @@ -263,17 +263,20 @@ function obj_to_yaml(obj_path::String, output_dir::String; end """ - resolve_aero_geometry(yaml_in, out_dir; table_format=:csv, verbose=true) -> yaml_out + resolve_aero_geometry(yaml_in, out_dir; table_format=:csv, ml_models_dir=nothing, + 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` table, others pass through). The generated polars go to +`neuralfoil` and `masure_regression` → `polars` table, others pass through); +`ml_models_dir` holds the masure regression models. 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; - table_format::Symbol=:csv, verbose=true) + table_format::Symbol=:csv, ml_models_dir=nothing, + verbose=true) mkpath(out_dir) polar_dir = joinpath(out_dir, "polars") mkpath(polar_dir) @@ -296,7 +299,7 @@ function resolve_aero_geometry(yaml_in::String, out_dir::String; end new_type, new_info = resolve_airfoil(String(row[ti]), info, polar_dir, row[idi]; Re, alpha_range, - table_format) + table_format, ml_models_dir) row[ti] = new_type row[ii] = new_info end diff --git a/test/airfoil_aero/data/masure/ET_re1e6.npz b/test/airfoil_aero/data/masure/ET_re1e6.npz new file mode 100644 index 0000000000000000000000000000000000000000..1dbcadb5774ae208b501491a8089d3e4f4f90d57 GIT binary patch literal 47626 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 XztKNhaI{fOp7`n~4mN%*5d8FiRz=Q$ literal 0 HcmV?d00001 diff --git a/test/airfoil_aero/data/masure/reference.npz b/test/airfoil_aero/data/masure/reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..e4b03e6e7e1dcae09b749e6d8d397c68dcc4d0c1 GIT binary patch literal 2810 zcmc(hcTkkq8pfBlv@1=GG?5b83agaJ29)<35{fhdr3fORz(vXeyNKb6h;q>d0s#pM zSrQ=>4T2&|S=xRdYgnZ!EK(NfMFdnz1T08IR)OE&l$jj3=EC%(0Cw{7MvQ zw#>x`PF8~tcWq%`>K!n@lh|_Ycow{Jt!ES$s$yCX1kQg?=z-hJ8Dp(K#$iu9IclO! z87tNg?bdW$SW&=6(N#@YSSFr{qmKd>Zvv;_g2g$bx%X-;s`!h ze+6cDNoV=4FrmI4Xe1tP07p@=7n>0Kg5+FodfjowUQg2xmHIuq}v zx$7Y#kG&+^_A?>kp|53=UO%v(6`Z`~-7ukn42Cy$woDKs(2qm7-6A z%R*qt+>a)Ri1h2`qRB{?&<=&;@c52RdtYKcsa#)gD4hD*4J&SYz2!rX~nXF zVW5@$+xFfyA*9*KIbF`B1a_7zDh?KNp-;?iCxTChm|@jNyHN=&{*0|b&z278AF82N z;Adb(%(RFguY#%E5~{3%`?62+#5d#kgk} zl>XC-vcI_r5(N1)!jq*iJwK=C!ADpyn$z2EPT;}5S4oS$p;Zu2Kr*;tun{4;OCme< zG5A#^(XUi}7D!iD$aIGF<=!UeX_m- zpjhJ47j?G-rc$=5%?o$H6AA52f*Z6E+P4giw4dtWr`0?yx%z(ihHGRvxwQ<=*5>g} zlyAZws!YYp%3*My|41Kawi7N!ZP%ZWSHm`mt+8CL)j**eUH@iy7o^0!G$cl2fIHh+ zPKX+S$Ew8stUwH$|CKy0syhoZMVGf^xa%W-SGQns=d*O3V{@OS`+vRj#WEo=bAj>6 z|0z#CE-6DXL{wWvH2ejh{(5M?nl5YISPg{9jy_{~7T71m^alm9fus1909hUb-qWF> z!2mWag+5|m3Tpt}`EZ?DaTc7L+Q*GCs)4+;kl_QnS>U|4@>*ja3jROT7t|To!{)BB zF&+y8uB;x{CX)q)LC-_WBI^M+@XWnMtPWJ9<@ct3i9wm!l2chY1{yRye32#wjfBh` zBW3m{Q-)|_-ZOx~$K;$J78O#SgLA)pkq}0x1D=@zrNVR`Y6rLJa^i+z^IY+ro08EB16nu63v@md7+ zai~ZVtd)%%0saQ#nQhbs;0@WP`H#;)3TfE3P`wKtyZb!(aZ5IO{g$Ir2YV5A;UmZw zSW7Txlj`fo8-<7n>H3|&5^n56SYYEd7@N;TuQMl|jjofX4iqmh>fsTp_-eH0Qb z&fR|xwUgJG(;jO=x3frgO~xFwCU|xKkZ>E?-&1epvDo~{+@m=}i&JVxKc<5XAHS}^ zhxv0oVy+LNzf$QsV)@qRw`ufa$$F2jx8eHuP$DUe!@ccT2OFWc@s%IPkMn 0.08, "eta" => 0.2, "kappa" => 0.09, "delta" => -2.0, + "lambda" => 0.2, "phi" => 0.6) + 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" => 1e6, + "headers" => ["airfoil_id", "type", "info_dict"], + "data" => [Any[1, "masure_regression", params]]))) + @test_throws "needs ml_models_dir" resolve_aero_geometry( + yaml_in, joinpath(dir, "out"); verbose=false) + yaml_out = resolve_aero_geometry(yaml_in, joinpath(dir, "out"); + ml_models_dir=fixture_dir, verbose=false) + airfoil = YAML.load_file(yaml_out)["wing_airfoils"]["data"][1] + @test airfoil[2] == "polars" + section = Wing(yaml_out; n_panels=2).unrefined_sections[1] + @test section.aero_model == POLAR_VECTORS + cl, _, _ = masure_aero(load_masure_model(1e6, fixture_dir), params, [2.0]) + @test section.aero_data[1][4] ≈ deg2rad(2.0) + @test section.aero_data[2][4] ≈ cl[1] + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 39350918..9599109b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -70,6 +70,7 @@ function include_selected_tests() should_run_test("yaml_geometry/test_yaml_geometry.jl") && include("yaml_geometry/test_yaml_geometry.jl") should_run_test("airfoil_aero/test_airfoil_aero.jl") && include("airfoil_aero/test_airfoil_aero.jl") should_run_test("airfoil_aero/test_live_polar.jl") && include("airfoil_aero/test_live_polar.jl") + should_run_test("airfoil_aero/test_masure_regression.jl") && include("airfoil_aero/test_masure_regression.jl") should_run_test("obj_adapter/test_obj_adapter.jl") && include("obj_adapter/test_obj_adapter.jl") should_run_test("surfplan/test_surfplan.jl") && include("surfplan/test_surfplan.jl") # bin/release is a bash script, so only the unix runners can run it. From 43a8efe400a1cea1ae06f1abcf7331504a9bfe17 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:27:17 +0200 Subject: [PATCH 2/3] Tidy the masure evaluator, document it and log it in the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ docs/src/private_functions.md | 1 + src/airfoil_aero/masure_regression.jl | 43 +++++++++++---------- test/airfoil_aero/test_masure_regression.jl | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b303475..ffb37fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- `masure_regression` airfoils `{t, eta, kappa, delta, lambda, phi}` resolve to polars: + `resolve_aero_geometry(...; ml_models_dir)` evaluates the Masure Extra-Trees regression + (`masure_aero`, `load_masure_model`) in pure Julia, from models converted once with + `scripts/export_masure_models.py`. - `plot_geometry`, `plot_distribution`, `plot_combined_analysis`, `plot_section_polars`, `plot_airfoil_fit` and `plot_airfoils` take `show_title=true`; with `false` the title is not drawn, and still names the saved file and window. diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index f6da3f59..30bfcdb0 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -233,6 +233,7 @@ sigmoid MASURE_PARAMETERS MASURE_REYNOLDS predict +scale_input ``` ### Polars and airfoil IO diff --git a/src/airfoil_aero/masure_regression.jl b/src/airfoil_aero/masure_regression.jl index 56c24c9d..27b1d1ac 100644 --- a/src/airfoil_aero/masure_regression.jl +++ b/src/airfoil_aero/masure_regression.jl @@ -1,10 +1,5 @@ -""" -Masure regression - pure Julia evaluation of the Extra-Trees models that predict the -polars of a parametric leading-edge-inflatable (LEI) airfoil. - -The trained scikit-learn models are https://doi.org/10.5281/zenodo.16925758; -`scripts/export_masure_models.py` converts them to the `.npz` files read here. -""" +# Masure regression: the Extra-Trees models of https://doi.org/10.5281/zenodo.16925758, +# converted to .npz by scripts/export_masure_models.py, evaluated as sklearn does. "Airfoil parameters the masure regression takes, in its input order (alpha follows)." const MASURE_PARAMETERS = ("t", "eta", "kappa", "delta", "lambda", "phi") @@ -12,8 +7,6 @@ const MASURE_PARAMETERS = ("t", "eta", "kappa", "delta", "lambda", "phi") "Reynolds numbers a masure regression model exists for, with their file suffix." const MASURE_REYNOLDS = Dict(1.0e6 => "1e6", 5.0e6 => "5e6", 2.0e7 => "2e7") -const _MASURE_CACHE = Dict{Tuple{Float64,String}, Any}() - """ ExtraTreesForest @@ -47,11 +40,13 @@ struct MasureModel forests::Vector{ExtraTreesForest} end +const MASURE_CACHE = Dict{Tuple{Float64,String}, MasureModel}() + function ExtraTreesForest(data::AbstractDict, prefix::String) - array(name) = data["$(prefix)_$(name)"] - return ExtraTreesForest(array("roots") .+ Int32(1), array("left") .+ Int32(1), - array("right") .+ Int32(1), array("feature") .+ Int32(1), - array("threshold"), array("value")) + one_based(name) = data["$(prefix)_$(name)"] .+ Int32(1) + return ExtraTreesForest(one_based("roots"), one_based("left"), one_based("right"), + one_based("feature"), data["$(prefix)_threshold"], + data["$(prefix)_value"]) end """ @@ -64,14 +59,14 @@ function load_masure_model(Re::Real, ml_models_dir::AbstractString) haskey(MASURE_REYNOLDS, Re) || error("No masure regression model for Re = $Re; " * "available: $(join(sort!(collect(keys(MASURE_REYNOLDS))), ", ")).") path = joinpath(ml_models_dir, "ET_re$(MASURE_REYNOLDS[Re]).npz") - get!(_MASURE_CACHE, (Float64(Re), abspath(path))) do + return get!(MASURE_CACHE, (Float64(Re), abspath(path))) do isfile(path) || error("Masure regression model not found at $path. Download " * "the models from https://doi.org/10.5281/zenodo.16925758 and convert them " * "with scripts/export_masure_models.py.") data = npzread(path) MasureModel(data["input_mean"], data["input_scale"], [ExtraTreesForest(data, "output$k") for k in 0:2]) - end::MasureModel + end end """ @@ -100,17 +95,23 @@ Lift, drag and moment coefficients of the LEI airfoil described by `params` (key [deg]. """ function masure_aero(model::MasureModel, params::AbstractDict, alpha::AbstractVector) - raw = [Float64(params[name]) for name in MASURE_PARAMETERS] - x = zeros(Float32, length(raw) + 1) + x = [scale_input(model, params[name], j) for (j, name) in enumerate(MASURE_PARAMETERS)] + push!(x, 0.0f0) coefficients = zeros(length(alpha), length(model.forests)) for (i, angle) in enumerate(alpha) - for (j, value) in enumerate((raw..., Float64(angle))) - # sklearn trees compare the scaled input rounded to Float32. - x[j] = Float32((value - model.input_mean[j]) / model.input_scale[j]) - end + x[end] = scale_input(model, angle, length(x)) for (k, forest) in enumerate(model.forests) coefficients[i, k] = predict(forest, x) end end return coefficients[:, 2], coefficients[:, 1], coefficients[:, 3] end + +""" + scale_input(model, value, j) -> Float32 + +Input `j` standardised as the model's scaler does, rounded to `Float32` as sklearn trees +compare it. +""" +scale_input(model::MasureModel, value::Real, j::Int) = + Float32((Float64(value) - model.input_mean[j]) / model.input_scale[j]) diff --git a/test/airfoil_aero/test_masure_regression.jl b/test/airfoil_aero/test_masure_regression.jl index cef73107..b227ec72 100644 --- a/test/airfoil_aero/test_masure_regression.jl +++ b/test/airfoil_aero/test_masure_regression.jl @@ -8,7 +8,7 @@ using VortexStepMethod.AirfoilAero: MASURE_PARAMETERS, load_masure_model, masure fixture_dir = joinpath(@__DIR__, "data", "masure") @testset "masure regression" begin - @testset "Extra-Trees evaluation matches sklearn predict, rows on a split included" begin + @testset "Extra-Trees evaluation matches sklearn predict, on-split rows too" begin reference = npzread(joinpath(fixture_dir, "reference.npz")) model = load_masure_model(1e6, fixture_dir) for (row, expected) in zip(eachrow(reference["X"]), eachrow(reference["Y"])) From 7132853ba62265b8f10e28cc752ad868c8a1bd72 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:17:39 +0200 Subject: [PATCH 3/3] Pin masure parity bit for bit and name missing masure parameters The parity test compares with == instead of atol=1e-12. resolve_airfoil names the airfoil and the missing keys instead of a bare KeyError. load_masure_model caches on the model path alone, without the do-closure; predict is renamed forest_predict; the exporter's fixture helper reads the pipeline steps positionally, as export_model does. New prose is unwrapped, and the docs say outside the trained alpha range the trees are constant. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +-- docs/src/airfoil_pipeline.md | 13 +------ docs/src/private_functions.md | 3 +- scripts/export_masure_models.py | 2 +- src/airfoil_aero/airfoil_io.jl | 3 ++ src/airfoil_aero/masure_regression.jl | 40 ++++++++++----------- test/airfoil_aero/test_masure_regression.jl | 12 +++++-- 7 files changed, 38 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb37fa7..a3d41c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,7 @@ ### Added -- `masure_regression` airfoils `{t, eta, kappa, delta, lambda, phi}` resolve to polars: - `resolve_aero_geometry(...; ml_models_dir)` evaluates the Masure Extra-Trees regression - (`masure_aero`, `load_masure_model`) in pure Julia, from models converted once with - `scripts/export_masure_models.py`. +- `masure_regression` airfoils `{t, eta, kappa, delta, lambda, phi}` resolve to polars: `resolve_aero_geometry(...; ml_models_dir)` evaluates the Masure Extra-Trees regression (`masure_aero`, `load_masure_model`) in pure Julia, from models converted once with `scripts/export_masure_models.py`. - `plot_geometry`, `plot_distribution`, `plot_combined_analysis`, `plot_section_polars`, `plot_airfoil_fit` and `plot_airfoils` take `show_title=true`; with `false` the title is not drawn, and still names the saved file and window. diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index d38c6567..9cc66fa8 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -126,18 +126,7 @@ so generated geometry files stay diff-friendly and consistent. ## Parametric LEI airfoils: the masure regression -A geometry YAML can also describe a leading-edge-inflatable airfoil by six shape -parameters instead of a slice, as the `wing_airfoils` entry -`[id, masure_regression, {t, eta, kappa, delta, lambda, phi}]`: tube diameter, chordwise -and vertical position of maximum camber, trailing-edge reflex angle [deg], camber tension -and leading-edge tension. [`resolve_aero_geometry`](@ref VortexStepMethod.ObjAdapter.resolve_aero_geometry) -turns it into a `POLAR_VECTORS` table over the block's `alpha_range` with -[`masure_aero`](@ref), which evaluates the Extra-Trees regression of K.R.G. Masure, -trained on 2D RANS simulations, in pure Julia. The models exist for `reynolds` 1e6, 5e6 -and 2e7. Download them from [Zenodo](https://doi.org/10.5281/zenodo.16925758), convert -them once with `python scripts/export_masure_models.py ET_re*.pkl --out DIR` (needs numpy -and scikit-learn), and pass `ml_models_dir=DIR`. The converted model gives the same -coefficients as scikit-learn's `predict`. +A geometry YAML can also describe a leading-edge-inflatable airfoil by six shape parameters instead of a slice, as the `wing_airfoils` entry `[id, masure_regression, {t, eta, kappa, delta, lambda, phi}]`: tube diameter, chordwise and vertical position of maximum camber, trailing-edge reflex angle [deg], camber tension and leading-edge tension. [`resolve_aero_geometry`](@ref VortexStepMethod.ObjAdapter.resolve_aero_geometry) turns it into a `POLAR_VECTORS` table over the block's `alpha_range` with [`masure_aero`](@ref), which evaluates the Extra-Trees regression of K.R.G. Masure, trained on 2D RANS simulations, in pure Julia. The models exist for `reynolds` 1e6, 5e6 and 2e7. Download them from [Zenodo](https://doi.org/10.5281/zenodo.16925758), convert them once with `python scripts/export_masure_models.py ET_re*.pkl --out DIR` (needs numpy and scikit-learn), and pass `ml_models_dir=DIR`. The converted model gives the same coefficients as scikit-learn's `predict`. Keep `alpha_range` within the angles the models were trained on: outside them the trees return a constant. ## Why this is useful diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index 30bfcdb0..51018425 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -232,7 +232,8 @@ sigmoid ```@docs MASURE_PARAMETERS MASURE_REYNOLDS -predict +forest_predict +one_based scale_input ``` diff --git a/scripts/export_masure_models.py b/scripts/export_masure_models.py index 3fb0fdfd..25022e56 100644 --- a/scripts/export_masure_models.py +++ b/scripts/export_masure_models.py @@ -72,7 +72,7 @@ def export_model(model, npz_path): def threshold_rows(model, row): """Copies of `row` moved onto each output's first root split, where float32 matters.""" - scaler, multi_output = model.named_steps["scale"], model.named_steps["model"] + scaler, multi_output = (step for _, step in model.steps) rows = [] for forest in multi_output.estimators_: tree = forest.estimators_[0].tree_ diff --git a/src/airfoil_aero/airfoil_io.jl b/src/airfoil_aero/airfoil_io.jl index fe549d76..3beb97e7 100644 --- a/src/airfoil_aero/airfoil_io.jl +++ b/src/airfoil_aero/airfoil_io.jl @@ -92,6 +92,9 @@ function resolve_airfoil(type::AbstractString, info::AbstractDict, out_dir, id; elseif type == "masure_regression" ml_models_dir === nothing && error("masure_regression airfoil $id needs ml_models_dir.") + absent = [name for name in MASURE_PARAMETERS if !haskey(info, name)] + isempty(absent) || error("masure_regression airfoil $id lacks " * + "$(join(absent, ", ")); it takes $(join(MASURE_PARAMETERS, ", ")).") alpha = collect(Float64, alpha_range) cl, cd, cm = masure_aero(load_masure_model(Re, ml_models_dir), info, alpha) write_polar(polar, deg2rad.(alpha), cl, cd, cm) diff --git a/src/airfoil_aero/masure_regression.jl b/src/airfoil_aero/masure_regression.jl index 27b1d1ac..40d8ee29 100644 --- a/src/airfoil_aero/masure_regression.jl +++ b/src/airfoil_aero/masure_regression.jl @@ -1,6 +1,3 @@ -# Masure regression: the Extra-Trees models of https://doi.org/10.5281/zenodo.16925758, -# converted to .npz by scripts/export_masure_models.py, evaluated as sklearn does. - "Airfoil parameters the masure regression takes, in its input order (alpha follows)." const MASURE_PARAMETERS = ("t", "eta", "kappa", "delta", "lambda", "phi") @@ -40,13 +37,17 @@ struct MasureModel forests::Vector{ExtraTreesForest} end -const MASURE_CACHE = Dict{Tuple{Float64,String}, MasureModel}() +const MASURE_CACHE = Dict{String, MasureModel}() + +"Index array `key` of an exported model, shifted from 0-based to 1-based." +one_based(data::AbstractDict, key::String) = data[key] .+ Int32(1) function ExtraTreesForest(data::AbstractDict, prefix::String) - one_based(name) = data["$(prefix)_$(name)"] .+ Int32(1) - return ExtraTreesForest(one_based("roots"), one_based("left"), one_based("right"), - one_based("feature"), data["$(prefix)_threshold"], - data["$(prefix)_value"]) + return ExtraTreesForest(one_based(data, "$(prefix)_roots"), + one_based(data, "$(prefix)_left"), + one_based(data, "$(prefix)_right"), + one_based(data, "$(prefix)_feature"), + data["$(prefix)_threshold"], data["$(prefix)_value"]) end """ @@ -58,23 +59,22 @@ Load the masure regression model for Reynolds number `Re` (1e6, 5e6 or 2e7) from function load_masure_model(Re::Real, ml_models_dir::AbstractString) haskey(MASURE_REYNOLDS, Re) || error("No masure regression model for Re = $Re; " * "available: $(join(sort!(collect(keys(MASURE_REYNOLDS))), ", ")).") - path = joinpath(ml_models_dir, "ET_re$(MASURE_REYNOLDS[Re]).npz") - return get!(MASURE_CACHE, (Float64(Re), abspath(path))) do - isfile(path) || error("Masure regression model not found at $path. Download " * - "the models from https://doi.org/10.5281/zenodo.16925758 and convert them " * - "with scripts/export_masure_models.py.") - data = npzread(path) - MasureModel(data["input_mean"], data["input_scale"], - [ExtraTreesForest(data, "output$k") for k in 0:2]) - end + path = abspath(joinpath(ml_models_dir, "ET_re$(MASURE_REYNOLDS[Re]).npz")) + haskey(MASURE_CACHE, path) && return MASURE_CACHE[path] + isfile(path) || error("Masure regression model not found at $path. Download " * + "the models from https://doi.org/10.5281/zenodo.16925758 and convert them " * + "with scripts/export_masure_models.py.") + data = npzread(path) + return MASURE_CACHE[path] = MasureModel(data["input_mean"], data["input_scale"], + [ExtraTreesForest(data, "output$k") for k in 0:2]) end """ - predict(forest::ExtraTreesForest, x) -> Float64 + forest_predict(forest::ExtraTreesForest, x) -> Float64 Mean of the leaf values the scaled input `x` reaches in each tree. """ -function predict(forest::ExtraTreesForest, x::AbstractVector{Float32}) +function forest_predict(forest::ExtraTreesForest, x::AbstractVector{Float32}) total = 0.0 for root in forest.roots node = root @@ -101,7 +101,7 @@ function masure_aero(model::MasureModel, params::AbstractDict, alpha::AbstractVe for (i, angle) in enumerate(alpha) x[end] = scale_input(model, angle, length(x)) for (k, forest) in enumerate(model.forests) - coefficients[i, k] = predict(forest, x) + coefficients[i, k] = forest_predict(forest, x) end end return coefficients[:, 2], coefficients[:, 1], coefficients[:, 3] diff --git a/test/airfoil_aero/test_masure_regression.jl b/test/airfoil_aero/test_masure_regression.jl index b227ec72..da1969e0 100644 --- a/test/airfoil_aero/test_masure_regression.jl +++ b/test/airfoil_aero/test_masure_regression.jl @@ -3,7 +3,7 @@ import YAML using VortexStepMethod using VortexStepMethod.ObjAdapter: resolve_aero_geometry using VortexStepMethod.AirfoilAero: MASURE_PARAMETERS, load_masure_model, masure_aero, - write_yaml, npzread + resolve_airfoil, write_yaml, npzread fixture_dir = joinpath(@__DIR__, "data", "masure") @@ -14,7 +14,7 @@ fixture_dir = joinpath(@__DIR__, "data", "masure") for (row, expected) in zip(eachrow(reference["X"]), eachrow(reference["Y"])) params = Dict(zip(MASURE_PARAMETERS, row[1:6])) cl, cd, cm = masure_aero(model, params, [row[7]]) - @test [cd[1], cl[1], cm[1]] ≈ expected atol=1e-12 + @test [cd[1], cl[1], cm[1]] == expected end end @@ -25,6 +25,14 @@ fixture_dir = joinpath(@__DIR__, "data", "masure") 5e6, fixture_dir) end + @testset "resolve_airfoil names the airfoil and the missing parameters" begin + info = Dict("t" => 0.08, "eta" => 0.2, "kappa" => 0.09, "delta" => -2.0, + "lamba" => 0.2, "phi" => 0.6) + @test_throws "masure_regression airfoil 3 lacks lambda" resolve_airfoil( + "masure_regression", info, mktempdir(), 3; Re=1e6, alpha_range=[0.0], + ml_models_dir=fixture_dir) + end + @testset "resolve_aero_geometry turns masure_regression into a loadable polar" begin dir = mktempdir() yaml_in = joinpath(dir, "awesio.yaml")