diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6407e6c9..48fdc83d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,7 +23,7 @@ jobs: matrix: version: - '1.10' - - '1' + - '1.11' os: - ubuntu-latest build_is_production_build: diff --git a/.gitignore b/.gitignore index 04fba002..6232db14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -Manifest.toml +Manifest*.toml .vscode/settings.json venv results/TUDELFT_V3_LEI_KITE/polars/$C_L$ vs $C_D$.pdf @@ -9,3 +9,4 @@ results/TUDELFT_V3_LEI_KITE/polars/tutorial_testing_stall_model_n_panels_54_dist !test/data/*.bin Manifest-v1.11.toml Manifest-v1.10.toml +CLAUDE.md diff --git a/NEWS.md b/NEWS.md index d368160a..e7493c1b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,27 @@ +## VortexStepMethod [Unreleased] +### Changed +- Unified `Wing` and `RamAirWing` into single `Wing` type (`RamAirWing` now alias for `ObjWing`) +- Renamed `ram_geometry.jl` to `obj_geometry.jl` +- Wing geometry uses unrefined sections with automatic panel-to-section mapping +- Consistent naming: variables ending in `_dist` are per-panel, `_unrefined_dist` per unrefined section +- `VSMSolution` field names: `panel_width_array` → `width_dist`, `alpha_array` → `alpha_dist`, etc. +- Enhanced Makie extension with `plot_combined_analysis` for combined plotting + +### Added +- `n_unrefined_sections` field in `Wing` for tracking pre-refinement sections +- `refined_panel_mapping` for automatic panel-to-section association +- Unrefined distribution fields in `VSMSolution`: `cl_unrefined_dist`, `cd_unrefined_dist`, `cm_unrefined_dist`, `alpha_unrefined_dist`, `moment_unrefined_dist` +- `PanelDistribution.NONE` for wings already refined +- Kwarg `sort_sections` for section ordering +- YAML wing deformation tests +- Unrefined distribution tests + +### Removed +- Panel grouping (replaced with unrefined section mapping) +- `PanelGroupingMethod` enum (deprecated, grouping automatic via mapping) +- `n_groups` and `grouping_method` from settings files and structs +- `n_groups` field from `WingSettings` and `SolverSettings` + ## VortexStepMethod v2.3.0 2025-10-16 ### Added - A Makie plotting extension. diff --git a/Project.toml b/Project.toml index 6841b218..7609394f 100644 --- a/Project.toml +++ b/Project.toml @@ -38,16 +38,11 @@ VortexStepMethodControlPlotsExt = "ControlPlots" VortexStepMethodMakieExt = "Makie" [compat] -Aqua = "0.8" -BenchmarkTools = "1" -CSV = "0.10" Colors = "0.13" ControlPlots = "0.2.5" -DataFrames = "1.7" DefaultApplication = "1" DelimitedFiles = "1" DifferentiationInterface = "0.7.4" -Documenter = "1.8" FiniteDiff = "2.27.0" Interpolations = "0.15, 0.16" LaTeXStrings = "1" @@ -60,28 +55,13 @@ Parameters = "0.12" Pkg = "1" PreallocationTools = "0.4.31" PrecompileTools = "1.2.1" -Random = "1.10.0" RecursiveArrayTools = "3 - 3.36.0" SciMLBase = "2.77.0" Serialization = "1" StaticArrays = "1" Statistics = "1" StructMapping = "0.2.3" -Test = "1" Timers = "0.1" Xfoil = "1.1.0" YAML = "0.4.13" julia = "1.10, 1.11" - -[extras] -Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" -BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" -CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -ControlPlots = "23c2ee80-7a9e-4350-b264-8e670f12517c" -DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[targets] -test = ["Test", "DataFrames", "CSV", "Documenter", "BenchmarkTools", "ControlPlots", "Aqua", "Random"] diff --git a/README.md b/README.md index de01a449..d93c3a0a 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ if you haven't already. On Linux, make sure that Python3 and Matplotlib are inst ``` sudo apt install python3-matplotlib ``` -Furthermore, the packages `TestEnv` and `ControlPlots` must be installed globally: +Furthermore, the package `ControlPlots` must be installed globally: ``` -julia -e 'using Pkg; Pkg.add("TestEnv"); Pkg.add("ControlPlots")' +julia -e 'using Pkg; Pkg.add("ControlPlots")' ``` Before installing this software it is suggested to create a new project, for example like this: diff --git a/bin/install b/bin/install index e4609513..49b359ad 100755 --- a/bin/install +++ b/bin/install @@ -20,7 +20,7 @@ fi export JULIA_PKG_SERVER_REGISTRY_PREFERENCE=eager -julia -e 'using Pkg; Pkg.add("TestEnv"); Pkg.add("ControlPlots")' +julia -e 'using Pkg; Pkg.add("ControlPlots")' julia --project -e 'include("bin/install.jl")' diff --git a/data/TUDELFT_V3_KITE/vsm_settings.yaml b/data/TUDELFT_V3_KITE/vsm_settings.yaml index cef41285..9f185704 100644 --- a/data/TUDELFT_V3_KITE/vsm_settings.yaml +++ b/data/TUDELFT_V3_KITE/vsm_settings.yaml @@ -30,7 +30,6 @@ # NEWTON: Newton-Raphson nonlinear solver # # USAGE NOTES: -# - n_panels should be divisible by n_groups for proper load balancing # - Higher n_panels improves accuracy but increases computation time # - Lower relaxation_factor if convergence issues occur @@ -46,7 +45,6 @@ wings: - name: V3_Kite # Wing identifier for output labeling geometry_file: data/TUDELFT_V3_KITE/aero_geometry.yaml n_panels: 36 # Total number of panels along wingspan - n_groups: 1 # Number of panel groups (must divide n_panels) spanwise_panel_distribution: LINEAR # Panel spacing algorithm spanwise_direction: [0.0, 1.0, 0.0] # Unit vector defining wingspan direction remove_nan: true # Remove NaN values from polar data diff --git a/data/pyramid_model/vsm_settings.yaml b/data/pyramid_model/vsm_settings.yaml index 93ba2ca8..5ab7053d 100644 --- a/data/pyramid_model/vsm_settings.yaml +++ b/data/pyramid_model/vsm_settings.yaml @@ -30,7 +30,6 @@ # NEWTON: Newton-Raphson nonlinear solver # # USAGE NOTES: -# - n_panels should be divisible by n_groups for proper load balancing # - Higher n_panels improves accuracy but increases computation time # - Lower relaxation_factor if convergence issues occur @@ -46,7 +45,6 @@ wings: - name: V3_Kite # Wing identifier for output labeling geometry_file: data/pyramid_model/wing_geometry.yaml n_panels: 2 # Total number of panels along wingspan - n_groups: 1 # Number of panel groups (must divide n_panels) spanwise_panel_distribution: LINEAR # Panel spacing algorithm spanwise_direction: [0.0, 1.0, 0.0] # Unit vector defining wingspan direction remove_nan: true # Remove NaN values from polar data diff --git a/data/ram_air_kite/vsm_settings.yaml b/data/ram_air_kite/vsm_settings.yaml index 5ae66dc9..92372c10 100644 --- a/data/ram_air_kite/vsm_settings.yaml +++ b/data/ram_air_kite/vsm_settings.yaml @@ -10,17 +10,14 @@ PanelDistribution: InitialGammaDistribution: ELLIPTIC: Elliptic distribution ZEROS: Constant distribution - wings: - name: main_wing n_panels: 40 - n_groups: 40 - spanwise_panel_distribution: LINEAR + spanwise_panel_distribution: NONE spanwise_direction: [0.0, 1.0, 0.0] remove_nan: true solver_settings: n_panels: 40 - n_groups: 40 aerodynamic_model_type: VSM density: 1.225 # air density [kg/m³] max_iterations: 1500 diff --git a/data/ram_air_kite/vsm_settings_dual.yaml b/data/ram_air_kite/vsm_settings_dual.yaml index 307cf14d..5c4a9359 100644 --- a/data/ram_air_kite/vsm_settings_dual.yaml +++ b/data/ram_air_kite/vsm_settings_dual.yaml @@ -14,13 +14,11 @@ InitialGammaDistribution: wings: - name: main_wing n_panels: 40 - n_groups: 40 spanwise_panel_distribution: LINEAR spanwise_direction: [0.0, 1.0, 0.0] remove_nan: true - name: tail n_panels: 20 - n_groups: 20 spanwise_panel_distribution: COSINE spanwise_direction: [0.0, 1.1, 0.0] remove_nan: false diff --git a/docs/make.jl b/docs/make.jl index 17d42209..677baf72 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,9 +1,3 @@ -using Pkg -if ("TestEnv" ∈ keys(Pkg.project().dependencies)) - if ! ("Documents" ∈ keys(Pkg.project().dependencies)) - using TestEnv; TestEnv.activate() - end -end using ControlPlots using VortexStepMethod using Documenter diff --git a/docs/src/glossary.md b/docs/src/glossary.md index a67c577d..940c6b5d 100644 --- a/docs/src/glossary.md +++ b/docs/src/glossary.md @@ -5,6 +5,7 @@ | AIC | Aerodynamic Influence Coefficient (AIC). The AIC matrix represents the relationship between the induced velocities or pressures on aerodynamic surfaces and the circulation strength or modal deformations of the lifting surfaces.| | inviscid | A fluid flow in which viscosity is considered negligible or zero. This means that there is no internal friction between the fluid layers, and the effects of viscosity on the flow are assumed to be insignificant. | | Panel | Flat surface element in 3D that approximate the contour of the aerodynamic body being studied.| +| Panel Group | A collection of panels whose aerodynamic forces and moments are summed together. Groups can be defined using EQUAL_SIZE (sequential grouping) or REFINE (based on original unrefined structure) methods.| | Section |A wing section, also known as an airfoil or aerofoil, is the cross-sectional shape of an aircraft wing.| | Span | Distance from one wing tip to the other wing tip. | | Polar | The polar typically plots the coefficient of lift (CL) against the coefficient of drag (CD), with the angle of attack as a parameter along the curve. | diff --git a/docs/src/index.md b/docs/src/index.md index fa6d54c1..3310d72c 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -18,9 +18,9 @@ if you haven't already. On Linux, make sure that Python3 and Matplotlib are inst ``` sudo apt install python3-matplotlib ``` -Furthermore, the packages `TestEnv` and `ControlPlots` must be installed globally: +Furthermore, the package `ControlPlots` must be installed globally: ``` -julia -e 'using Pkg; Pkg.add("TestEnv"); Pkg.add("ControlPlots")' +julia -e 'using Pkg; Pkg.add("ControlPlots")' ``` Before installing this software it is suggested to create a new project, for example like this: diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index d99dc45b..3646579c 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -7,5 +7,5 @@ CurrentModule = VortexStepMethod calculate_AIC_matrices! update_panel_properties! calculate_inertia_tensor -group_deform! +unrefined_deform! ``` \ No newline at end of file diff --git a/docs/src/tips_and_tricks.md b/docs/src/tips_and_tricks.md index 64995f34..6e8d532f 100644 --- a/docs/src/tips_and_tricks.md +++ b/docs/src/tips_and_tricks.md @@ -10,6 +10,29 @@ The following bodies can be simulated: To build the geometry of a RAM-air kite, a 3D .obj file can be used as input. In addition a `.dat` file is needed. It should have two columns, one for the `x` and one for the `y` coordinate of the 2D polar that is used. +## Unrefined Section Distribution +When creating a wing, panel forces and moments are automatically computed for each unrefined section. The unrefined sections correspond to the original geometry sections you define, while panels represent the refined mesh used for aerodynamic calculations. + +### How It Works +The solver automatically tracks which panels belong to which original unrefined section. After refinement (e.g., splitting each section into multiple panels), the aerodynamic forces and moments are aggregated back to the unrefined section level. + +```julia +# Create wing with 4 sections, refined to 40 panels +wing = Wing(40) +add_section!(wing, [0, 5, 0], [1, 5, 0], INVISCID) # Section 1 +add_section!(wing, [0, 2.5, 0], [1, 2.5, 0], INVISCID) # Section 2 +add_section!(wing, [0, 0, 0], [1, 0, 0], INVISCID) # Section 3 +add_section!(wing, [0, -5, 0], [1, -5, 0], INVISCID) # Section 4 +``` + +The 40 panels are distributed across the 3 unrefined panels (sections 1-2, 2-3, 3-4). Forces and moments are computed per panel during the solve, then automatically aggregated to the 3 unrefined panels for output. + +This approach is useful for: +- LEI kites where you want loads per rib +- Wings with discrete control surfaces +- Cases where physical structure doesn't align with uniform panel distribution +- Dynamic simulations where you have fewer structural segments than panels needed for accurate VSM aerodynamics. For example, a 6-segment structural model can be combined with 40-panel aerodynamics, with loads automatically mapped back to the 6 structural segments. + ## RAM-air kite model If running the example `ram_air_kite.jl` fails, try to run the `cleanup.jl` script and then try again. Background: this example caches the calculated polars. Reading cached polars can fail after an update. diff --git a/docs/src/types.md b/docs/src/types.md index 14e5cbdd..9b76a7d0 100644 --- a/docs/src/types.md +++ b/docs/src/types.md @@ -7,6 +7,7 @@ Model WingType AeroModel PanelDistribution +PanelGroupingMethod InitialGammaDistribution SolverStatus ``` @@ -24,17 +25,17 @@ AeroData ``` ## Wing Geometry, Panel and Aerodynamics -A body is constructed of one or more abstract wings. An abstract wing can be a Wing or a RamAirWing. -A Wing/ RamAirWing has one or more sections. +A body is constructed of one or more abstract wings. All wings are of type Wing. +A Wing has one or more sections and can be created from YAML files or OBJ geometry. ```@docs Section Section(LE_point::PosVector, TE_point::PosVector, aero_model) Wing Wing(n_panels::Int; spanwise_distribution::PanelDistribution=LINEAR, spanwise_direction::PosVector=MVec3([0.0, 1.0, 0.0])) -RamAirWing -RamAirWing(obj_path, dat_path; alpha=0.0, crease_frac=0.75, wind_vel=10., mass=1.0, - n_panels=54, n_sections=n_panels+1, spanwise_distribution=UNCHANGED, +ObjWing +ObjWing(obj_path, dat_path; alpha=0.0, crease_frac=0.75, wind_vel=10., mass=1.0, + n_panels=54, n_sections=n_panels+1, spanwise_distribution=UNCHANGED, spanwise_direction=[0.0, 1.0, 0.0]) BodyAerodynamics ``` diff --git a/examples/Project.toml b/examples/Project.toml new file mode 100644 index 00000000..f6082789 --- /dev/null +++ b/examples/Project.toml @@ -0,0 +1,11 @@ +[deps] +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +ControlPlots = "23c2ee80-7a9e-4350-b264-8e670f12517c" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +VortexStepMethod = "ed3cd733-9f0f-46a9-93e0-89b8d4998dd9" + +[sources] +VortexStepMethod = {path = ".."} diff --git a/examples/V3_kite.jl b/examples/V3_kite.jl index 9f50b6d4..9dafd212 100644 --- a/examples/V3_kite.jl +++ b/examples/V3_kite.jl @@ -1,6 +1,10 @@ using LinearAlgebra using VortexStepMethod -using ControlPlots +using GLMakie + +PLOT = true +USE_TEX = false +DEFORM = false project_dir = dirname(dirname(pathof(VortexStepMethod))) # Go up one level from src to project root# literature_paths = [ @@ -11,8 +15,8 @@ literature_paths = [ ] labels= [ "Julia VSM 2D CFD PCHIP", - "CFD RANS Re=5e5", - "CFD RANS Re=10e5 (With Struts)", + "CFD RANS Re=5e5", + "CFD RANS Re=10e5 (With Struts)", "Python VSM 2D CFD PCHIP Re=5e5", "Wind Tunnel Re=5e5 (With Struts)" ] @@ -22,7 +26,20 @@ settings = VSMSettings("TUDELFT_V3_KITE/vsm_settings.yaml") # Create wing, body_aero, and solver objects using settings wing = Wing(settings) +refine!(wing) body_aero = BodyAerodynamics([wing]) +VortexStepMethod.reinit!(body_aero) + +if DEFORM + VortexStepMethod.unrefined_deform!( + wing, + deg2rad.(range(-10, 10, length=wing.n_unrefined_sections)), + deg2rad.(range(0, 0, length=wing.n_unrefined_sections)); + smooth=true + ) + VortexStepMethod.reinit!(body_aero; init_aero=false) +end + solver = Solver(body_aero, settings) # Set flight conditions from settings @@ -38,53 +55,23 @@ yaw_rate = settings.condition.yaw_rate PLOT = true USE_TEX = false -# Plotting polars -PLOT && plot_polars( - [solver], - [body_aero], - labels, +# Solve and plot combined analysis +results = VortexStepMethod.solve(solver, body_aero; log=true) +PLOT && plot_combined_analysis( + solver, + body_aero, + results; + solver_label="VSM", literature_path_list=literature_paths, angle_range=range(-5, 25, length=30), angle_type="angle_of_attack", angle_of_attack=angle_of_attack_deg, side_slip=sideslip_deg, v_a=wind_speed, - title="$(wing.n_panels)_panels_$(wing.spanwise_distribution)_from_yaml_settings", - data_type=".pdf", - is_save=false, - is_show=true, - use_tex=USE_TEX -) - - -# Plotting geometry -results = VortexStepMethod.solve(solver, body_aero; log=true) -PLOT && plot_geometry( - body_aero, - ""; - data_type=".svg", - save_path="", - is_save=false, - is_show=true, - view_elevation=15, - view_azimuth=-120, - use_tex=USE_TEX -) - - -# Plotting spanwise distributions -body_y_coordinates = [panel.aero_center[2] for panel in body_aero.panels] - -PLOT && plot_distribution( - [body_y_coordinates], - [results], - ["VSM"]; - title="CAD_spanwise_distributions_alpha_$(round(angle_of_attack_deg, digits=1))_delta_$(round(sideslip_deg, digits=1))_yaw_$(round(yaw_rate, digits=1))_v_a_$(round(wind_speed, digits=1))", - data_type=".pdf", - is_save=false, + title="TU Delft V3 Kite", is_show=true, use_tex=USE_TEX ) -nothing \ No newline at end of file +nothing diff --git a/examples/bench.jl b/examples/bench.jl index 89544278..6c2f8726 100644 --- a/examples/bench.jl +++ b/examples/bench.jl @@ -2,12 +2,6 @@ using LinearAlgebra using ControlPlots using VortexStepMethod -using Pkg - -if !("CSV" ∈ keys(Pkg.project().dependencies)) - using TestEnv - TestEnv.activate() -end # Step 1: Define wing parameters n_panels = 20 # Number of panels @@ -56,9 +50,9 @@ println("Rectangular wing, solve:") @time solve(vsm_solver, body_aero, nothing) # Create wing geometry -wing = RamAirWing( - joinpath("data", "ram_air_kite", "ram_air_kite_body.obj"), - joinpath("data", "ram_air_kite", "ram_air_kite_foil.dat"); +wing = ObjWing( + joinpath("data", "ram_air_kite", "ram_air_kite_body.obj"), + joinpath("data", "ram_air_kite", "ram_air_kite_foil.dat"); prn=false ) body_aero = BodyAerodynamics([wing]) diff --git a/examples/menu.jl b/examples/menu.jl index 4454ccee..147990c3 100644 --- a/examples/menu.jl +++ b/examples/menu.jl @@ -1,8 +1,3 @@ -using Pkg -if ! ("ControlPlots" ∈ keys(Pkg.project().dependencies)) - using TestEnv; TestEnv.activate() -end - using ControlPlots using VortexStepMethod using REPL.TerminalMenus diff --git a/examples/pyramid_model.jl b/examples/pyramid_model.jl index 837e26ef..bc2f0d2a 100644 --- a/examples/pyramid_model.jl +++ b/examples/pyramid_model.jl @@ -1,6 +1,6 @@ using LinearAlgebra using VortexStepMethod -using ControlPlots +using GLMakie project_dir = dirname(dirname(pathof(VortexStepMethod))) # Go up one level from src to project root @@ -9,6 +9,7 @@ vsm_settings = VSMSettings("pyramid_model/vsm_settings.yaml") # Create wing, body_aero, and solver objects using vsm_settings wing = Wing(vsm_settings) +refine!(wing) body_aero = BodyAerodynamics([wing]) solver = Solver(body_aero, vsm_settings) @@ -28,47 +29,18 @@ results = VortexStepMethod.solve(solver, body_aero; log=true) PLOT = true USE_TEX = false -# Plotting polars -PLOT && plot_polars( - [solver], - [body_aero], - ["VSM Pyramid Model"], +# Plotting combined analysis +PLOT && plot_combined_analysis( + solver, + body_aero, + results; + solver_label="VSM", angle_range=range(-5, 25, length=30), angle_type="angle_of_attack", angle_of_attack=angle_of_attack_deg, side_slip=sideslip_deg, v_a=wind_speed, - title="$(wing.n_panels)_panels_$(wing.spanwise_distribution)_pyramid_model", - data_type=".pdf", - is_save=false, - is_show=true, - use_tex=USE_TEX -) - -# Plotting geometry -results = VortexStepMethod.solve(solver, body_aero; log=true) -PLOT && plot_geometry( - body_aero, - ""; - data_type=".svg", - save_path="", - is_save=false, - is_show=true, - view_elevation=15, - view_azimuth=-120, - use_tex=USE_TEX -) - -# Plotting spanwise distributions -body_y_coordinates = [panel.aero_center[2] for panel in body_aero.panels] - -PLOT && plot_distribution( - [body_y_coordinates], - [results], - ["VSM"]; - title="pyramid_spanwise_distributions_alpha_$(round(angle_of_attack_deg, digits=1))_delta_$(round(sideslip_deg, digits=1))_yaw_$(round(yaw_rate, digits=1))_v_a_$(round(wind_speed, digits=1))", - data_type=".pdf", - is_save=false, + title="Pyramid Model", is_show=true, use_tex=USE_TEX ) diff --git a/examples/ram_air_kite.jl b/examples/ram_air_kite.jl index f10b8d5b..cc180023 100644 --- a/examples/ram_air_kite.jl +++ b/examples/ram_air_kite.jl @@ -1,17 +1,18 @@ -using ControlPlots +using GLMakie using VortexStepMethod using LinearAlgebra PLOT = true PRN = true USE_TEX = false -DEFORM = false +DEFORM = true LINEARIZE = false # Create wing geometry -wing = RamAirWing( - joinpath("data", "ram_air_kite", "ram_air_kite_body.obj"), - joinpath("data", "ram_air_kite", "ram_air_kite_foil.dat"); +wing = ObjWing( + joinpath("data", "ram_air_kite", "ram_air_kite_body.obj"), + joinpath("data", "ram_air_kite", "ram_air_kite_foil.dat"); + n_unrefined_sections=2, prn=PRN ) body_aero = BodyAerodynamics([wing];) @@ -19,9 +20,8 @@ println("First init") @time VortexStepMethod.reinit!(body_aero) if DEFORM - # Linear interpolation of alpha from 10° at one tip to 0° at the other println("Deform") - @time VortexStepMethod.smooth_deform!(wing, deg2rad.([10,20,10,0]), deg2rad.([-10,0,-10,0])) + @time VortexStepMethod.unrefined_deform!(wing, deg2rad.([-10,0]), deg2rad.([0,0]); smooth=true) println("Deform init") @time VortexStepMethod.reinit!(body_aero; init_aero=false) end @@ -67,55 +67,29 @@ if LINEARIZE moment_frac=0.1) end -# Plotting polar data -PLOT && plot_polar_data(body_aero) - -# Plotting geometry -PLOT && plot_geometry( - body_aero, - ""; - data_type=".svg", - save_path="", - is_save=false, - is_show=true, - view_elevation=15, - view_azimuth=-120, - use_tex=USE_TEX -) - -# Solving and plotting distributions +# Solving println("Solve") results = VortexStepMethod.solve(solver, body_aero; log=true) @time results = solve(solver, body_aero; log=true) body_y_coordinates = [panel.aero_center[2] for panel in body_aero.panels] -PLOT && plot_distribution( - [body_y_coordinates], - [results], - ["VSM"]; - title="CAD_spanwise_distributions_alpha_$(round(aoa, digits=1))_delta_$(round(side_slip, digits=1))_yaw_$(round(yaw_rate, digits=1))_v_a_$(round(v_a, digits=1))", - data_type=".pdf", - is_save=false, - is_show=true, - use_tex=USE_TEX -) - -PLOT && plot_polars( - [solver], - [body_aero], - [ - "VSM from Ram Air Kite OBJ and DAT file", - ]; - angle_range=range(0, 20, length=20), - angle_type="angle_of_attack", - angle_of_attack=0, - side_slip=0, - v_a=10, - title="ram_kite_panels_$(wing.n_panels)_distribution_$(wing.spanwise_distribution)", - data_type=".pdf", - is_save=false, - is_show=true, - use_tex=USE_TEX -) -nothing \ No newline at end of file +if PLOT + plot_combined_analysis( + solver, + body_aero, + results; + solver_label="VSM", + angle_range=range(0, 20, length=20), + angle_type="angle_of_attack", + angle_of_attack=aoa, + side_slip=side_slip, + v_a=v_a, + title="Ram Air Kite (α=$(aoa)°, β=$(side_slip)°, v=$(v_a) m/s)", + view_elevation=15, + view_azimuth=-120, + is_show=true, + use_tex=USE_TEX + ) +end +nothing diff --git a/examples/rectangular_wing.jl b/examples/rectangular_wing.jl index 94dc5aec..7053337d 100644 --- a/examples/rectangular_wing.jl +++ b/examples/rectangular_wing.jl @@ -1,5 +1,5 @@ using LinearAlgebra -using ControlPlots +using GLMakie using VortexStepMethod PLOT = true @@ -18,15 +18,18 @@ alpha = deg2rad(alpha_deg) wing = Wing(n_panels, spanwise_distribution=LINEAR) # Add wing sections - defining only tip sections with inviscid airfoil model -add_section!(wing, - [0.0, span/2, 0.0], # Left tip LE +add_section!(wing, + [0.0, span/2, 0.0], # Left tip LE [chord, span/2, 0.0], # Left tip TE INVISCID) -add_section!(wing, +add_section!(wing, [0.0, -span/2, 0.0], # Right tip LE [chord, -span/2, 0.0], # Right tip TE INVISCID) +# Refine mesh +refine!(wing) + # Step 3: Initialize aerodynamics body_aero = BodyAerodynamics([wing]) @@ -53,38 +56,18 @@ println("CL = $(round(results_vsm["cl"], digits=4))") println("CD = $(round(results_vsm["cd"], digits=4))") println("Projected area = $(round(results_vsm["projected_area"], digits=4)) m²") -# Step 6: Plot geometry -PLOT && plot_geometry( - body_aero, - "Rectangular_wing_geometry"; - data_type=".pdf", - save_path=".", - is_save=false, - is_show=true, - use_tex=USE_TEX -) - -# Step 7: Plot spanwise distributions -y_coordinates = [panel.aero_center[2] for panel in body_aero.panels] - -PLOT && plot_distribution( - [y_coordinates, y_coordinates], - [results_vsm, results_llt], - ["VSM", "LLT"], - title="Spanwise Distributions", - use_tex=USE_TEX -) - -# Step 8: Plot polar curves +# Step 6: Plot combined analysis angle_range = range(0, 20, 20) -PLOT && plot_polars( +PLOT && plot_combined_analysis( [llt_solver, vsm_solver], [body_aero, body_aero], - ["LLT", "VSM"]; - angle_range, + [results_llt, results_vsm]; + solver_label=["LLT", "VSM"], + angle_range=angle_range, angle_type="angle_of_attack", - v_a, - title="Rectangular Wing Polars", + v_a=v_a, + title="Rectangular Wing", + is_show=true, use_tex=USE_TEX ) nothing diff --git a/examples/stall_model.jl b/examples/stall_model.jl index 6253faca..fe83e718 100644 --- a/examples/stall_model.jl +++ b/examples/stall_model.jl @@ -1,11 +1,7 @@ -using ControlPlots +using GLMakie using LinearAlgebra using VortexStepMethod -using Pkg -if ! ("CSV" ∈ keys(Pkg.project().dependencies)) - using TestEnv; TestEnv.activate() -end using CSV using DataFrames @@ -38,10 +34,12 @@ for row in eachrow(df) end # Create wing geometry +# n_unrefined_sections will be automatically set to the number of ribs (18 sections) CAD_wing = Wing(n_panels; spanwise_distribution) for rib in rib_list add_section!(CAD_wing, rib[1], rib[2], rib[3], rib[4]) end +refine!(CAD_wing) body_aero = BodyAerodynamics([CAD_wing]) # Create solvers @@ -67,40 +65,12 @@ vel_app = [ ] * v_a set_va!(body_aero, vel_app) -# Plotting geometry -PLOT && plot_geometry( - body_aero, - ""; - data_type=".svg", - save_path="", - is_save=false, - is_show=true, - view_elevation=15, - view_azimuth=-120, - use_tex=USE_TEX -) - -# Solving and plotting distributions +# Solve both configurations results = solve(vsm_solver, body_aero) @time results_with_stall = solve(VSM_with_stall_correction, body_aero) @time results_with_stall = solve(VSM_with_stall_correction, body_aero) -CAD_y_coordinates = [panel.aero_center[2] for panel in body_aero.panels] - -PLOT && plot_distribution( - [CAD_y_coordinates, CAD_y_coordinates], - [results, results_with_stall], - ["VSM", "VSM with stall correction"]; - title="CAD_spanwise_distributions_alpha_$(round(aoa, digits=1))_delta_$(round(side_slip, digits=1))_yaw_$(round(yaw_rate, digits=1))_v_a_$(round(v_a, digits=1))", - data_type=".pdf", - save_path=joinpath(save_folder, "spanwise_distributions"), - is_save=false, - is_show=true, - use_tex=USE_TEX -) - -# Plotting polar -save_path = joinpath(root_dir, "results", "TUD_V3_LEI_KITE") +# Setup literature data paths path_cfd_lebesque = joinpath( root_dir, "data", @@ -109,24 +79,22 @@ path_cfd_lebesque = joinpath( "V3_CL_CD_RANS_Lebesque_2024_Rey_300e4.csv" ) -PLOT && plot_polars( +# Only include literature data if file exists +literature_paths = isfile(path_cfd_lebesque) ? [path_cfd_lebesque] : String[] + +# Plot combined analysis +PLOT && plot_combined_analysis( [vsm_solver, VSM_with_stall_correction], [body_aero, body_aero], - [ - "VSM CAD 19ribs", - "VSM CAD 19ribs , with stall correction", - "CFD_Lebesque Rey 30e5" - ]; - literature_path_list=[path_cfd_lebesque], + [results, results_with_stall]; + solver_label=["VSM", "VSM (with stall)"], + literature_path_list=literature_paths, angle_range=range(0, 25, length=25), angle_type="angle_of_attack", - angle_of_attack=0, - side_slip=0, - v_a=10, - title="tutorial_testing_stall_model_n_panels_$(n_panels)_distribution_$(spanwise_distribution)", - data_type=".pdf", - save_path=joinpath(save_folder, "polars"), - is_save=true, + angle_of_attack=aoa, + side_slip=side_slip, + v_a=v_a, + title="Stall Model Comparison", is_show=true, use_tex=USE_TEX ) diff --git a/ext/VortexStepMethodControlPlotsExt.jl b/ext/VortexStepMethodControlPlotsExt.jl index ce9494be..bbf985c7 100644 --- a/ext/VortexStepMethodControlPlotsExt.jl +++ b/ext/VortexStepMethodControlPlotsExt.jl @@ -3,8 +3,1016 @@ using ControlPlots, LaTeXStrings, VortexStepMethod, LinearAlgebra, Statistics, D import ControlPlots: plt import VortexStepMethod: calculate_filaments_for_plotting -export plot_wing, plot_circulation_distribution, plot_geometry, plot_distribution, plot_polars, save_plot, show_plot, plot_polar_data +export plot_wing, plot_circulation_distribution, plot_geometry, plot_distribution, + plot_polars, save_plot, show_plot, plot_polar_data, plot_combined_analysis -include("../src/plotting.jl") +""" + set_plot_style(titel_size=16; use_tex=false) + +Set the default style for plots using LaTeX. +`` +# Arguments: +- `titel_size`: size of the plot title in points (default: 16) +- `ùse_tex`: if the external `pdflatex` command shall be used +""" +function set_plot_style(titel_size=16; use_tex=false) + rcParams = plt.PyDict(plt.matplotlib."rcParams") + rcParams["text.usetex"] = use_tex + rcParams["font.family"] = "serif" + if use_tex + rcParams["font.serif"] = ["Computer Modern Roman"] + end + rcParams["axes.titlesize"] = titel_size + rcParams["axes.labelsize"] = 12 + rcParams["axes.linewidth"] = 1 + rcParams["lines.linewidth"] = 1 + rcParams["lines.markersize"] = 6 + rcParams["xtick.labelsize"] = 10 + rcParams["ytick.labelsize"] = 10 + rcParams["legend.fontsize"] = 10 + rcParams["figure.titlesize"] = 16 + if use_tex + rcParams["pgf.texsystem"] = "pdflatex" # Use pdflatex + end + rcParams["pgf.rcfonts"] = false + rcParams["figure.figsize"] = (10, 6) # Default figure size +end + + +""" + save_plot(fig, save_path, title; data_type=".pdf") + +Save a plot to a file. + +# Arguments +- `fig`: Plots figure object +- `save_path`: Path to save the plot +- `title`: Title of the plot + +# Keyword arguments +- `data_type`: File extension (default: ".pdf") +""" +function VortexStepMethod.save_plot(fig, save_path, title; data_type=".pdf") + isnothing(save_path) && throw(ArgumentError("save_path should be provided")) + + !isdir(save_path) && mkpath(save_path) + full_path = joinpath(save_path, title * data_type) + + @debug "Attempting to save figure to: $full_path" + @debug "Current working directory: $(pwd())" + + try + fig.savefig(full_path) + @debug "Figure saved as $data_type" + + if isfile(full_path) + @debug "File successfully saved to $full_path" + @debug "File size: $(filesize(full_path)) bytes" + else + @info "File does not exist after save attempt: $full_path" + end + catch e + @error "Error saving figure: $e" + @error "Error type: $(typeof(e))" + rethrow(e) + end +end + +""" + show_plot(fig; dpi=130) + +Display a plot at specified DPI. + +# Arguments +- `fig`: Plots figure object + +# Keyword arguments +- `dpi`: Dots per inch for the figure (default: 130) +""" +function VortexStepMethod.show_plot(fig; dpi=130) + plt.display(fig) +end + +""" + plot_line_segment!(ax, segment, color, label; width=3) + +Plot a line segment in 3D with arrow. + +# Arguments +- `ax`: Plot axis +- `segment`: Array of two points defining the segment +- `color`: Color of the segment +- `label`: Label for the legend + +# Keyword Arguments +- `width`: Line width (default: 3) +""" +function plot_line_segment!(ax, segment, color, label; width=3) + ax.plot( + [segment[1][1], segment[2][1]], + [segment[1][2], segment[2][2]], + [segment[1][3], segment[2][3]], + color=color, label=label, linewidth=width + ) + + dir = segment[2] - segment[1] + ax.quiver( + [segment[1][1]], [segment[1][2]], [segment[1][3]], + [dir[1]], [dir[2]], [dir[3]], + color=color + ) +end + +""" + set_axes_equal!(ax; zoom=1.8) + +Set 3D plot axes to equal scale. + +# Arguments +- ax: 3D plot axis + +# Keyword arguments +zoom: zoom factor (default: 1.8) +""" +function set_axes_equal!(ax; zoom=1.8) + x_lims = ax.get_xlim3d() ./ zoom + y_lims = ax.get_ylim3d() ./ zoom + z_lims = ax.get_zlim3d() ./ zoom + + x_range = abs(x_lims[2] - x_lims[1]) + y_range = abs(y_lims[2] - y_lims[1]) + z_range = abs(z_lims[2] - z_lims[1]) + + max_range = max(x_range, y_range, z_range) + + x_mid = mean(x_lims) + y_mid = mean(y_lims) + z_mid = mean(z_lims) + + ax.set_xlim3d((x_mid - max_range / 2, x_mid + max_range / 2)) + ax.set_ylim3d((y_mid - max_range / 2, y_mid + max_range / 2)) + ax.set_zlim3d((z_mid - max_range / 2, z_mid + max_range / 2)) +end + +""" + create_geometry_plot(body_aero::BodyAerodynamics, title, view_elevation, view_azimuth; + zoom=1.8, use_tex=false) + +Create a 3D plot of wing geometry including panels and filaments. + +# Arguments +- body_aero: struct of type BodyAerodynamics +- title: plot title +- view_elevation: initial view elevation angle [°] +- view_azimuth: initial view azimuth angle [°] + +# Keyword arguments +- zoom: zoom factor (default: 1.8) +""" +function create_geometry_plot(body_aero::BodyAerodynamics, title, view_elevation, view_azimuth; + zoom=1.8, use_tex=false) + set_plot_style(28; use_tex) + + panels = body_aero.panels + va = isa(body_aero.va, Tuple) ? body_aero.va[1] : body_aero.va + + # Extract geometric data + corner_points = [panel.corner_points for panel in panels] + control_points = [panel.control_point for panel in panels] + aero_centers = [panel.aero_center for panel in panels] + + # Create plot + fig = plt.figure(figsize=(14, 14)) + ax = fig.add_subplot(111, projection="3d") + ax.set_title(title) + + # Plot panels + legend_used = Dict{String,Bool}() + for (i, panel) in enumerate(panels) + # Plot panel edges and surfaces + corners = Matrix{Float64}(panel.corner_points) + x_corners = corners[1, :] + y_corners = corners[2, :] + z_corners = corners[3, :] + + push!(x_corners, x_corners[1]) + push!(y_corners, y_corners[1]) + push!(z_corners, z_corners[1]) + + ax.plot(x_corners, + y_corners, + z_corners, + color=:grey, + linewidth=1, + label=i == 1 ? "Panel Edges" : "") + + # Plot control points and aerodynamic centers + ax.scatter([control_points[i][1]], [control_points[i][2]], [control_points[i][3]], + color=:green, label=i == 1 ? "Control Points" : "") + ax.scatter([aero_centers[i][1]], [aero_centers[i][2]], [aero_centers[i][3]], + color=:blue, label=i == 1 ? "Aerodynamic Centers" : "") + + # Plot filaments + filaments = calculate_filaments_for_plotting(panel) + legends = ["Bound Vortex", "side1", "side2", "wake_1", "wake_2"] + + for (filament, legend) in zip(filaments, legends) + x1, x2, color = filament + @debug "Legend: $legend" + show_legend = !get(legend_used, legend, false) + plot_line_segment!(ax, [x1, x2], color, show_legend ? legend : "") + legend_used[legend] = true + end + end + + # Plot velocity vector + max_chord = maximum(panel.chord for panel in panels) + va_mag = norm(va) + va_vector_begin = -2 * max_chord * va / va_mag + va_vector_end = va_vector_begin + 1.5 * va / va_mag + plot_line_segment!(ax, [va_vector_begin, va_vector_end], :lightblue, "va") + + # Add legends for the first occurrence of each label + handles, labels = ax.get_legend_handles_labels() + # by_label = Dict(zip(labels, handles)) + # ax.legend(values(by_label), keys(by_label), bbox_to_anchor=(0, 0, 1.1, 1)) + + # Set labels and make axes equal + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_zlabel("z") + set_axes_equal!(ax; zoom) + + # Set the initial view + ax.view_init(elev=view_elevation, azim=view_azimuth) + + # Ensure the figure is fully rendered + # fig.canvas.draw() + plt.tight_layout(rect=(0, 0, 1, 0.97)) + + return fig +end + +""" + plot_geometry(body_aero::BodyAerodynamics, title; + data_type=".pdf", save_path=nothing, + is_save=false, is_show=false, + view_elevation=15, view_azimuth=-120, use_tex=false) + +Plot wing geometry from different viewpoints and optionally save/show plots. + +# Arguments: +- `body_aero`: the [BodyAerodynamics](@ref) to plot +- title: plot title + +# Keyword arguments: +- `data_type``: string with the file type postfix (default: ".pdf") +- `save_path`: path for saving the graphic (default: `nothing`) +- `is_save`: boolean value, indicates if the graphic shall be saved (default: `false`) +- `is_show`: boolean value, indicates if the graphic shall be displayed (default: `false`) +- `view_elevation`: initial view elevation angle (default: 15) [°] +- `view_azimuth`: initial view azimuth angle (default: -120) [°] +- `use_tex`: if the external `pdflatex` command shall be used (default: false) + +""" +function VortexStepMethod.plot_geometry(body_aero::BodyAerodynamics, title; + data_type=".pdf", + save_path=nothing, + is_save=false, + is_show=false, + view_elevation=15, + view_azimuth=-120, + use_tex=false) + + if is_save + plt.ioff() + # Angled view + fig = create_geometry_plot(body_aero, "$(title)_angled_view", 15, -120; use_tex) + save_plot(fig, save_path, "$(title)_angled_view", data_type=data_type) + + # Top view + fig = create_geometry_plot(body_aero, "$(title)_top_view", 90, 0; use_tex) + save_plot(fig, save_path, "$(title)_top_view", data_type=data_type) + + # Front view + fig = create_geometry_plot(body_aero, "$(title)_front_view", 0, 0; use_tex) + save_plot(fig, save_path, "$(title)_front_view", data_type=data_type) + + # Side view + fig = create_geometry_plot(body_aero, "$(title)_side_view", 0, -90; use_tex) + save_plot(fig, save_path, "$(title)_side_view", data_type=data_type) + end + + if is_show + plt.ion() + fig = create_geometry_plot(body_aero, title, view_elevation, view_azimuth; use_tex) + plt.display(fig) + else + fig = create_geometry_plot(body_aero, title, view_elevation, view_azimuth; use_tex) + end + fig +end + +""" + plot_distribution(y_coordinates_list, results_list, label_list; + title="spanwise_distribution", data_type=".pdf", + save_path=nothing, is_save=false, is_show=true, use_tex=false) + +Plot spanwise distributions of aerodynamic properties. + +# Arguments +- `y_coordinates_list`: List of spanwise coordinates +- `results_list`: List of result dictionaries +- `label_list`: List of labels for different results + +# Keyword arguments +- `title`: Plot title (default: "spanwise_distribution") +- `data_type`: File extension for saving (default: ".pdf") +- `save_path`: Path to save plots (default: nothing) +- `is_save`: Whether to save plots (default: false) +- `is_show`: Whether to display plots (default: true) +- `use_tex`: if the external `pdflatex` command shall be used +""" +function VortexStepMethod.plot_distribution(y_coordinates_list, results_list, label_list; + title="spanwise_distribution", + data_type=".pdf", + save_path=nothing, + is_save=false, + is_show=true, + use_tex=false) + + length(results_list) == length(label_list) || throw(ArgumentError( + "Number of results ($(length(results_list))) must match number of labels ($(length(label_list)))" + )) + + # Set the plot style + set_plot_style(; use_tex) + + # Initializing plot + fig, axs = plt.subplots(3, 3, figsize=(16, 10)) + fig.suptitle(title, fontsize=16) + + # CL plot + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + value = "$(round(result_i["cl"], digits=2))" + if label_i == "LLT" + label = label_i * L" $~C_\mathrm{L}$: " * value + else + label = label_i * L" $C_\mathrm{L}$: " * value + end + axs[1, 1].plot( + y_coordinates_i, + result_i["cl_distribution"], + label=label + ) + end + axs[1, 1].set_title(L"$C_\mathrm{L}$ Distribution", size=16) + axs[1, 1].set_xlabel(L"Spanwise Position $y/b$") + axs[1, 1].set_ylabel(L"Lift Coefficient $C_\mathrm{L}$") + axs[1, 1].legend() + + # CD plot + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + value = "$(round(result_i["cl"], digits=2))" + if label_i == "LLT" + label = label_i * L" $~C_\mathrm{D}$: " * value + else + label = label_i * L" $C_\mathrm{D}$: " * value + end + axs[1, 2].plot( + y_coordinates_i, + result_i["cd_distribution"], + label=label + ) + end + axs[1, 2].set_title(L"$C_\mathrm{D}$ Distribution", size=16) + axs[1, 2].set_xlabel(L"Spanwise Position $y/b$") + axs[1, 2].set_ylabel(L"Drag Coefficient $C_\mathrm{D}$") + axs[1, 2].legend() + + # Gamma Distribution + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + axs[1, 3].plot( + y_coordinates_i, + result_i["gamma_distribution"], + label=label_i + ) + end + axs[1, 3].set_title(L"\Gamma~Distribution", size=16) + axs[1, 3].set_xlabel(L"Spanwise Position $y/b$") + axs[1, 3].set_ylabel(L"Circulation~\Gamma") + axs[1, 3].legend() + + # Geometric Alpha + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + axs[2, 1].plot( + y_coordinates_i, + result_i["alpha_geometric"], + label=label_i + ) + end + axs[2, 1].set_title(L"$\alpha$ Geometric", size=16) + axs[2, 1].set_xlabel(L"Spanwise Position $y/b$") + axs[2, 1].set_ylabel(L"Angle of Attack $\alpha$ (deg)") + axs[2, 1].legend() + + # Calculated/ Corrected Alpha + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + axs[2, 2].plot( + y_coordinates_i, + result_i["alpha_at_ac"], + label=label_i + ) + end + axs[2, 2].set_title(L"$\alpha$ result (corrected to aerodynamic center)", size=16) + axs[2, 2].set_xlabel(L"Spanwise Position $y/b$") + axs[2, 2].set_ylabel(L"Angle of Attack $\alpha$ (deg)") + axs[2, 2].legend() + + # Uncorrected Alpha plot + for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) + axs[2, 3].plot( + y_coordinates_i, + result_i["alpha_uncorrected"], + label=label_i + ) + end + axs[2, 3].set_title(L"$\alpha$ Uncorrected (if VSM, at the control point)", size=16) + axs[2, 3].set_xlabel(L"Spanwise Position $y/b$") + axs[2, 3].set_ylabel(L"Angle of Attack $\alpha$ (deg)") + axs[2, 3].legend() + + # Force Components + for (idx, component) in enumerate(["x", "y", "z"]) + axs[3, idx].set_title("Force in $component direction", size=16) + axs[3, idx].set_xlabel(L"Spanwise Position $y/b$") + axs[3, idx].set_ylabel(raw"$F_\mathrm" * "{$component}" * raw"$") + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + # Extract force components for the current direction (idx) + forces = results["F_distribution"][idx, :] + # Verify dimensions match + if length(y_coords) != length(forces) + @warn "Dimension mismatch in force plotting" length(y_coords) length(forces) component + continue # Skip this component instead of throwing error + end + space = "" + if label == "LLT" + space = "~" + end + axs[3, idx].plot( + y_coords, + forces, + label="$label" * space * raw"$~\Sigma~F_\mathrm" * "{$component}:~" * + raw"$" * "$(round(results["F$component"], digits=2)) N" + ) + axs[3, idx].legend() + end + end + + fig.tight_layout() + + # Save and show plot + if is_save + save_plot(fig, save_path, title, data_type=data_type) + end + + if is_show + show_plot(fig) + end + + return fig +end + +""" + generate_polar_data(solver, body_aero::BodyAerodynamics, angle_range; + angle_type="angle_of_attack", angle_of_attack=0.0, + side_slip=0.0, v_a=10.0, use_latex=false) + +Generate polar data for aerodynamic analysis over a range of angles. + +# Arguments +- `solver`: Aerodynamic solver object +- `body_aero`: Wing aerodynamics struct +- `angle_range`: Range of angles to analyze + +# Keyword arguments +- `angle_type`: Type of angle variation ("angle_of_attack" or "side_slip") +- `angle_of_attack`: Initial angle of attack [rad] +- `side_slip`: Initial side slip angle in [rad] +- `v_a`: norm of apparent wind speed [m/s] + +# Returns +- Tuple of polar data array and Reynolds number +""" +function generate_polar_data( + solver, + body_aero::BodyAerodynamics, + angle_range; + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0, + use_latex=false +) + n_panels = length(body_aero.panels) + n_angles = length(angle_range) + + # Initialize arrays + cl = zeros(n_angles) + cd = zeros(n_angles) + cs = zeros(n_angles) + gamma_distribution = zeros(n_angles, n_panels) + cl_distribution = zeros(n_angles, n_panels) + cd_distribution = zeros(n_angles, n_panels) + cs_distribution = zeros(n_angles, n_panels) + reynolds_number = zeros(n_angles) + + # Previous gamma for initialization + gamma = nothing + + for (i, angle_i) in enumerate(angle_range) + # Set angle based on type + if angle_type == "angle_of_attack" + α = deg2rad(angle_i) + β = side_slip + elseif angle_type == raw"side_slip" + α = angle_of_attack + β = deg2rad(angle_i) + else + throw(ArgumentError("angle_type must be 'angle_of_attack' or 'side_slip'")) + end + + # Update inflow conditions + set_va!( + body_aero, + [ + cos(α) * cos(β), + sin(β), + sin(α) + ] * v_a + ) + + # Solve and store results + results = solve(solver, body_aero, gamma_distribution[i, :]) + + cl[i] = results["cl"] + cd[i] = results["cd"] + cs[i] = results["cs"] + gamma_distribution[i, :] = results["gamma_distribution"] + cl_distribution[i, :] = results["cl_distribution"] + cd_distribution[i, :] = results["cd_distribution"] + cs_distribution[i, :] = results["cs_distribution"] + reynolds_number[i] = results["Rey"] + + # Store gamma for next iteration + gamma = gamma_distribution[i, :] + end + + polar_data = [ + angle_range, + cl, + cd, + cs, + gamma_distribution, + cl_distribution, + cd_distribution, + cs_distribution, + reynolds_number + ] + + return polar_data, reynolds_number[1] +end + +""" + plot_polars(solver_list, body_aero_list, label_list; + literature_path_list=String[], + angle_range=range(0, 20, 2), angle_type="angle_of_attack", + angle_of_attack=0.0, side_slip=0.0, v_a=10.0, + title="polar", data_type=".pdf", save_path=nothing, + is_save=true, is_show=true, use_tex=false) + +Plot polar data comparing different solvers and configurations. + +# Arguments +- `solver_list`: List of aerodynamic solvers +- `body_aero_list`: List of wing aerodynamics objects +- `label_list`: List of labels for each configuration + +# Keyword arguments +- `literature_path_list`: Optional paths to literature data files +- `angle_range`: Range of angles to analyze [°] +- `angle_type`: "`angle_of_attack`" or "`side_slip`"; (default: `angle_of_attack`) +- `angle_of_attack:` AoA to be used for plotting the polars (default: 0.0) [rad] +- `side_slip`: side slip angle (default: 0.0) [rad] +- v_a: norm of apparent wind speed (default: 10.0) [m/s] +- title: plot title +- `data_type`: File extension for saving (default: ".pdf") +- `save_path`: Path to save plots (default: nothing) +- `is_save`: Whether to save plots (default: true) +- `is_show`: Whether to display plots (default: true) +- `use_tex`: if the external `pdflatex` command shall be used (default: false) +""" +function VortexStepMethod.plot_polars( + solver_list, + body_aero_list, + label_list; + literature_path_list=String[], + angle_range=range(0, 20, 2), + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0, + title="polar", + data_type=".pdf", + save_path=nothing, + is_save=true, + is_show=true, + use_tex=false +) + # Validate inputs + total_cases = length(body_aero_list) + length(literature_path_list) + if total_cases != length(label_list) || length(solver_list) != length(body_aero_list) + throw(ArgumentError("Mismatch in number of solvers ($(length(solver_list))), " * + "cases ($total_cases), and labels ($(length(label_list)))")) + end + main_title = replace(title, " " => "_") + set_plot_style(; use_tex) + + # Generate polar data + polar_data_list = [] + for (i, (solver, body_aero)) in enumerate(zip(solver_list, body_aero_list)) + polar_data, rey = generate_polar_data( + solver, body_aero, angle_range; + angle_type, + angle_of_attack, + side_slip, + v_a + ) + push!(polar_data_list, polar_data) + # Update label with Reynolds number + label_list[i] = "$(label_list[i]) Re = $(round(Int64, rey*1e-5))e5" + end + # Load literature data if provided + if !isempty(literature_path_list) + for path in literature_path_list + data = readdlm(path, ',') + header = lowercase.(string.(data[1, :])) + # Find column indices for alpha, CL, CD, CS (case-insensitive, allow common variants) + alpha_idx = findfirst(x -> occursin("alpha", x), header) + cl_idx = findfirst(x -> occursin("cl", x), header) + cd_idx = findfirst(x -> occursin("cd", x), header) + cs_idx = findfirst(x -> occursin("cs", x), header) + # Fallback: if CS not found, fill with zeros + cs_col = cs_idx === nothing ? zeros(size(data, 1)-1) : data[2:end, cs_idx] + # Push as [alpha, CL, CD, CS] + push!(polar_data_list, [ + data[2:end, alpha_idx], + data[2:end, cl_idx], + data[2:end, cd_idx], + cs_col + ]) + end + end + + # Initializing plot + fig, axs = plt.subplots(2, 2, figsize=(14, 14)) + + # Number of computational results (excluding literature) + n_solvers = length(solver_list) + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) + if i < n_solvers + linestyle = "-" + marker = "*" + markersize = 7 + else + linestyle = "-" + marker = "." + markersize = 5 + end + if contains(label, "LLT") + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => raw"~") + label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") + label = raw"$" * label * raw"$" + else + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => "~") + label = replace(label, "VSM" => raw"\mathrm{VSM}") + label = raw"$" * label * raw"$" + end + axs[1, 1].plot( + polar_data[1], + polar_data[2], + label=label, + linestyle=linestyle, + marker=marker, + markersize=markersize, + ) + # Limit y-range if CL > 10 + if maximum(polar_data[2]) > 10 + axs[1, 1].set_ylim([-0.5, 2]) + end + title = raw"$C_\mathrm{L}" * raw"$" * " vs $angle_type [°]" + axs[1, 1].set_title(title) + axs[1, 1].set_xlabel("$angle_type [°]") + axs[1, 1].set_ylabel(L"$C_\mathrm{L}$") + axs[1, 1].legend() + end + + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) + if i < n_solvers + linestyle = "-" + marker = "*" + markersize = 7 + else + linestyle = "-" + marker = "." + markersize = 5 + end + if contains(label, "LLT") + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => raw"~") + label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") + label = raw"$" * label * raw"$" + else + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => "~") + label = replace(label, "VSM" => raw"\mathrm{VSM}") + label = raw"$" * label * raw"$" + end + axs[1, 2].plot( + polar_data[1], + polar_data[3], + label=label, + linestyle=linestyle, + marker=marker, + markersize=markersize, + ) + # Limit y-range if CL > 10 + if maximum(polar_data[2]) > 10 + axs[1, 2].set_ylim([-0.5, 2]) + end + title = raw"$C_\mathrm{D}" * raw"$" * " vs $angle_type [°]" + axs[1, 2].set_title(title) + axs[1, 2].set_xlabel("$angle_type [°]") + axs[1, 2].set_ylabel(L"$C_\mathrm{D}$") + axs[1, 2].legend() + end + + + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) + if i < n_solvers + linestyle = "-" + marker = "*" + markersize = 7 + else + linestyle = "-" + marker = "." + markersize = 5 + end + if contains(label, "LLT") + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => raw"~") + label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") + label = raw"$" * label * raw"$" + else + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => "~") + label = replace(label, "VSM" => raw"\mathrm{VSM}") + label = raw"$" * label * raw"$" + end + axs[2, 1].plot( + polar_data[1], + polar_data[4], + label=label, + linestyle=linestyle, + marker=marker, + markersize=markersize, + ) + # Limit y-range if CL > 10 + if maximum(polar_data[2]) > 10 + axs[2, 1].set_ylim([-0.5, 2]) + end + title = raw"$C_\mathrm{S}" * raw"$" * " vs $angle_type [°]" + axs[2, 1].set_title(title) + axs[2, 1].set_xlabel("$angle_type [°]") + axs[2, 1].set_ylabel(L"$C_\mathrm{S}$") + axs[2, 1].legend() + end + + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) + if i < n_solvers + linestyle = "-" + marker = "*" + markersize = 7 + else + linestyle = "-" + marker = "." + markersize = 5 + end + if contains(label, "LLT") + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => raw"~") + label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") + label = raw"$" * label * raw"$" + else + label = replace(label, "e5" => raw"\cdot10^5") + label = replace(label, " " => "~") + label = replace(label, "VSM" => raw"\mathrm{VSM}") + label = raw"$" * label * raw"$" + end + axs[2, 2].plot( + polar_data[3], + polar_data[2], + label=label, + linestyle=linestyle, + marker=marker, + markersize=markersize, + ) + # Limit y-range if CL > 10 + if maximum(polar_data[2]) > 10 || maximum(polar_data[3]) > 10 + axs[2, 2].set_ylim([-0.5, 2]) + axs[2, 2].set_xlim([-0.5, 2]) + end + title = raw"$C_\mathrm{L}" * raw"$" * " vs " * raw"$C_\mathrm{D}" * raw"$" + axs[2, 2].set_title(title) + axs[2, 2].set_xlabel(L"$C_\mathrm{D}$") + axs[2, 2].set_ylabel(L"$C_\mathrm{L}$") + axs[2, 2].legend() + end + + fig.tight_layout(h_pad=3.5, rect=(0.01, 0.01, 0.99, 0.99)) + + # Save and show plot + if is_save && !isnothing(save_path) + save_plot(fig, save_path, main_title; data_type) + end + + if is_show + show_plot(fig) + end + + return fig +end + +""" + plot_polar_data(body_aero::BodyAerodynamics; alphas=collect(deg2rad.(-5:0.3:25)), delta_tes=collect(deg2rad.(-5:0.3:25))) + +Plot polar data (Cl, Cd, Cm) as 3D surfaces against alpha and delta_te angles. delta_te is the trailing edge deflection angle +relative to the 2d airfoil or panel chord line. + +# Arguments +- `body_aero`: Wing aerodynamics struct + +# Keyword arguments +- `alphas`: Range of angle of attack values in radians (default: -5° to 25° in 0.3° steps) +- `delta_tes`: Range of trailing edge angles in radians (default: -5° to 25° in 0.3° steps) +- `is_show`: Whether to display plots (default: true) +- `use_tex`: if the external `pdflatex` command shall be used +""" +function VortexStepMethod.plot_polar_data(body_aero::BodyAerodynamics; + alphas=collect(deg2rad.(-5:0.3:25)), + delta_tes = collect(deg2rad.(-5:0.3:25)), + is_show = true, + use_tex = false + ) + if body_aero.panels[1].aero_model == POLAR_MATRICES + set_plot_style() + + # Create figure with subplots + fig = plt.figure(figsize=(15, 6)) + + # Get interpolation functions and labels + interp_data = [ + (body_aero.panels[1].cl_interp, L"$C_l$"), + (body_aero.panels[1].cd_interp, L"$C_d$"), + (body_aero.panels[1].cm_interp, L"$C_m$") + ] + + # Create each subplot + for (idx, (interp, label)) in enumerate(interp_data) + ax = fig.add_subplot(1, 3, idx, projection="3d") + + # Create interpolation matrix + interp_matrix = zeros(length(alphas), length(delta_tes)) + interp_matrix .= [interp(alpha, delta_te) for alpha in alphas, delta_te in delta_tes] + X = collect(delta_tes) .+ zeros(length(alphas))' + Y = collect(alphas)' .+ zeros(length(delta_tes)) + + # Plot surface + ax.plot_wireframe(X, Y, interp_matrix, + edgecolor="blue", + lw=0.5, + rstride=5, + cstride=5, + alpha=0.6) + + # Set labels and title + ax.set_xlabel(L"$\delta$ [rad]") + ax.set_ylabel(L"$\alpha$ [rad]") + ax.set_zlabel(label) + ax.set_title(label * L" vs $\alpha$ and $\delta$") + ax.grid(true) + end + + # Adjust layout and display + plt.tight_layout(rect=(0.01, 0.01, 0.99, 0.99)) + if is_show + show_plot(fig) + end + return fig + else + throw(ArgumentError("Plotting polar data for $(body_aero.panels[1].aero_model) is not implemented.")) + end +end + +""" + plot_combined_analysis(solver, body_aero, results; kwargs...) + +Create combined analysis by calling plot_geometry, plot_distribution, +and plot_polars sequentially. Each creates a separate matplotlib window. + +# Arguments +- `solver`: Solver or array of solvers +- `body_aero`: BodyAerodynamics object or array +- `results`: Results dict or array of results dicts + +See individual functions for detailed parameter descriptions. +""" +function VortexStepMethod.plot_combined_analysis( + solver, + body_aero, + results; + solver_label="VSM", + angle_range=range(0, 20, length=20), + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0, + title="Combined Analysis", + data_type=".pdf", + save_path=nothing, + is_save=false, + is_show=true, + view_elevation=15, + view_azimuth=-120, + use_tex=false, + literature_path_list=String[] +) + # Normalize inputs to arrays for consistent handling + solvers = solver isa Vector ? solver : [solver] + body_aeros = body_aero isa Vector ? body_aero : [body_aero] + results_list = results isa Vector ? results : [results] + labels = solver_label isa Vector ? solver_label : [solver_label] + + # Extract y-coordinates for distribution plot (use first body_aero) + body_y_coordinates = [panel.aero_center[2] for panel in body_aeros[1].panels] + y_coords_list = [body_y_coordinates for _ in 1:length(solvers)] + + # Plot geometry (only use first body_aero) + plot_geometry( + body_aeros[1], + title; + data_type=data_type, + save_path=save_path, + is_save=is_save, + is_show=is_show, + view_elevation=view_elevation, + view_azimuth=view_azimuth, + use_tex=use_tex + ) + + # Plot spanwise distributions + plot_distribution( + y_coords_list, + results_list, + labels; + title=title * " - Distributions", + data_type=data_type, + save_path=save_path, + is_save=is_save, + is_show=is_show, + use_tex=use_tex + ) + + # Plot polars + plot_polars( + solvers, + body_aeros, + labels; + literature_path_list=literature_path_list, + angle_range=angle_range, + angle_type=angle_type, + angle_of_attack=angle_of_attack, + side_slip=side_slip, + v_a=v_a, + title=title * " - Polars", + data_type=data_type, + save_path=save_path, + is_save=is_save, + is_show=is_show, + use_tex=use_tex + ) +end end \ No newline at end of file diff --git a/ext/VortexStepMethodMakieExt.jl b/ext/VortexStepMethodMakieExt.jl index f72ea3d8..62d5343c 100644 --- a/ext/VortexStepMethodMakieExt.jl +++ b/ext/VortexStepMethodMakieExt.jl @@ -1,55 +1,197 @@ module VortexStepMethodMakieExt -using Makie, VortexStepMethod +using Makie, VortexStepMethod, LinearAlgebra, Statistics, DelimitedFiles +import VortexStepMethod: calculate_filaments_for_plotting + +export plot_geometry, plot_distribution, plot_polars, save_plot, show_plot, + plot_polar_data, plot_combined_analysis + +# Global storage for panel mesh observables (for dynamic plotting) +const PANEL_MESH_OBSERVABLES = Ref{Union{Nothing, Dict}}(nothing) """ - plot!(ax, panel::VortexStepMethod.Panel; kwargs...) + plot!(ax, panel::VortexStepMethod.Panel; use_observables=false, kwargs...) Plot a single `Panel` as a `mesh`. The corner points are ordered as: LE1, TE1, TE2, LE2. This creates two triangles: (LE1, TE1, TE2) and (LE1, TE2, LE2). + +If `use_observables=true`, creates observables for dynamic updates. """ -function Makie.plot!(ax, panel::VortexStepMethod.Panel; color=(:red, 0.2), R_b_w=nothing, T_b_w=nothing, kwargs...) +function Makie.plot!(ax, panel::VortexStepMethod.Panel; color=(:red, 0.2), R_b_w=nothing, T_b_w=nothing, + use_observables=false, kwargs...) plots = [] points = [Point3f(panel.corner_points[:, i]) for i in 1:4] if !isnothing(R_b_w) && !isnothing(T_b_w) points = [Point3f(R_b_w * p + T_b_w) for p in points] end - faces = [Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)] - p = mesh!(ax, points, faces; color, transparency=true, kwargs...) - push!(plots, p) - border_points = [points..., points[1]] - p = lines!(ax, border_points; color=:black, transparency=true, kwargs...) - push!(plots, p) + + if use_observables + # Create observables for dynamic updates + vertices_obs = Observable(points) + faces_obs = Observable([Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)]) + border_obs = Observable([points..., points[1]]) + + p = mesh!(ax, vertices_obs, faces_obs; color, transparency=true, kwargs...) + push!(plots, p) + p = lines!(ax, border_obs; color=:black, transparency=true, kwargs...) + push!(plots, p) + + # Note: Observables are stored at the body level, not individual panel level + # Individual panels need their parent body for proper tracking + else + # Static plotting (original behavior) + faces = [Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)] + p = mesh!(ax, points, faces; color, transparency=true, kwargs...) + push!(plots, p) + border_points = [points..., points[1]] + p = lines!(ax, border_points; color=:black, transparency=true, kwargs...) + push!(plots, p) + end + return plots end """ - plot!(ax, body::VortexStepMethod.BodyAerodynamics; kwargs...) + plot!(ax, body::VortexStepMethod.BodyAerodynamics; use_observables=false, kwargs...) Plot a `BodyAerodynamics` object by plotting each of its panels. + +If `use_observables=true`, creates observables for dynamic updates keyed by (body_id, panel_index). +Otherwise, creates static plots (original behavior). """ -function Makie.plot!(ax, body::VortexStepMethod.BodyAerodynamics; color=(:red, 0.2), R_b_w=nothing, T_b_w=nothing, kwargs...) +function Makie.plot!(ax, body::VortexStepMethod.BodyAerodynamics; color=(:red, 0.2), R_b_w=nothing, T_b_w=nothing, + use_observables=false, kwargs...) plots = [] - for panel in body.panels - p = Makie.plot!(ax, panel; color, R_b_w, T_b_w, kwargs...) - push!(plots, p) + + if use_observables + # Initialize global storage if needed + if isnothing(PANEL_MESH_OBSERVABLES[]) + PANEL_MESH_OBSERVABLES[] = Dict() + end + + body_id = objectid(body) + + # Create observables for each panel + for (panel_idx, panel) in enumerate(body.panels) + # Compute initial points + points = [Point3f(panel.corner_points[:, i]) for i in 1:4] + if !isnothing(R_b_w) && !isnothing(T_b_w) + points = [Point3f(R_b_w * p + T_b_w) for p in points] + end + + # Create observables + vertices_obs = Observable(points) + faces_obs = Observable([Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)]) + border_obs = Observable([points..., points[1]]) + + # Plot using observables + p = mesh!(ax, vertices_obs, faces_obs; color, transparency=true, kwargs...) + push!(plots, p) + p = lines!(ax, border_obs; color=:black, transparency=true, kwargs...) + push!(plots, p) + + # Store observables with stable key + PANEL_MESH_OBSERVABLES[][(body_id, panel_idx)] = ( + vertices = vertices_obs, + border = border_obs, + faces = faces_obs + ) + end + else + # Static plotting (original behavior) + for panel in body.panels + p = Makie.plot!(ax, panel; color, R_b_w, T_b_w, use_observables=false, kwargs...) + push!(plots, p) + end end + return plots end -function Makie.plot(panel::VortexStepMethod.Panel; size = (1200, 800), kwargs...) + +""" + plot!(body::VortexStepMethod.BodyAerodynamics; R_b_w=nothing, T_b_w=nothing) + +Update existing body aerodynamics plot observables with current geometry. +This updates all panels in the body using their current corner_points. + +Requires that `plot(body; use_observables=true)` or `plot!(ax, body; use_observables=true)` +was called first to create the observables. +""" +function Makie.plot!(body::VortexStepMethod.BodyAerodynamics; R_b_w=nothing, T_b_w=nothing, kwargs...) + # Check if observables exist + if isnothing(PANEL_MESH_OBSERVABLES[]) + error("No panel observables found. Call plot(body; use_observables=true) first.") + end + + body_id = objectid(body) + + # Update each panel using stable (body_id, panel_idx) key + for (panel_idx, panel) in enumerate(body.panels) + key = (body_id, panel_idx) + if !haskey(PANEL_MESH_OBSERVABLES[], key) + error("No observables found for body $body_id panel $panel_idx. " * + "Call plot(body; use_observables=true) first.") + end + + # Get observables for this panel + obs = PANEL_MESH_OBSERVABLES[][key] + + # Recompute vertices from current panel.corner_points + points = [Point3f(panel.corner_points[:, i]) for i in 1:4] + if !isnothing(R_b_w) && !isnothing(T_b_w) + points = [Point3f(R_b_w * p + T_b_w) for p in points] + end + + # Update observables + obs.vertices[] = points + obs.border[] = [points..., points[1]] + end + + return nothing +end + +function Makie.plot(panel::VortexStepMethod.Panel; size = (1200, 800), + R_b_w=nothing, T_b_w=nothing, color=(:red, 0.2), kwargs...) fig = Figure(; size) ax = Axis3(fig[1, 1]; aspect = :data, xlabel = "X", ylabel = "Y", zlabel = "Z", azimuth = 9/8*π, zoommode = :cursor, viewmode = :fit, ) - plot!(ax, panel; kwargs...) + # Create observables for panel geometry + points = [Point3f(panel.corner_points[:, i]) for i in 1:4] + if !isnothing(R_b_w) && !isnothing(T_b_w) + points = [Point3f(R_b_w * p + T_b_w) for p in points] + end + + vertices_obs = Observable(points) + faces_obs = Observable([Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)]) + + # Plot mesh using observables + mesh!(ax, vertices_obs, faces_obs; color, transparency=true, kwargs...) + + # Plot border + border_obs = Observable([points..., points[1]]) + lines!(ax, border_obs; color=:black, transparency=true, kwargs...) + + # Store observables globally for updates + panel_id = objectid(panel) + if isnothing(PANEL_MESH_OBSERVABLES[]) + PANEL_MESH_OBSERVABLES[] = Dict() + end + PANEL_MESH_OBSERVABLES[][panel_id] = ( + vertices = vertices_obs, + border = border_obs, + faces = faces_obs + ) + return fig end function Makie.plot(body_aero::VortexStepMethod.BodyAerodynamics; size = (1200, 800), - limitmargin = 0.1, kwargs...) + limitmargin = 0.1, R_b_w=nothing, T_b_w=nothing, color=(:red, 0.2), + kwargs...) fig = Figure(; size) ax = Axis3(fig[1, 1]; aspect = :data, xlabel = "X", ylabel = "Y", zlabel = "Z", @@ -58,7 +200,996 @@ function Makie.plot(body_aero::VortexStepMethod.BodyAerodynamics; size = (1200, yautolimitmargin=(limitmargin, limitmargin), zautolimitmargin=(limitmargin, limitmargin), ) - plot!(ax, body_aero; kwargs...) + + # Initialize global storage if needed + if isnothing(PANEL_MESH_OBSERVABLES[]) + PANEL_MESH_OBSERVABLES[] = Dict() + end + + body_id = objectid(body_aero) + + # Create observables for each panel using stable (body_id, panel_idx) key + for (panel_idx, panel) in enumerate(body_aero.panels) + # Compute initial points + points = [Point3f(panel.corner_points[:, i]) for i in 1:4] + if !isnothing(R_b_w) && !isnothing(T_b_w) + points = [Point3f(R_b_w * p + T_b_w) for p in points] + end + + # Create observables + vertices_obs = Observable(points) + faces_obs = Observable([Makie.GLTriangleFace(1, 2, 3), Makie.GLTriangleFace(1, 3, 4)]) + border_obs = Observable([points..., points[1]]) + + # Plot using observables + mesh!(ax, vertices_obs, faces_obs; color, transparency=true, kwargs...) + lines!(ax, border_obs; color=:black, transparency=true, kwargs...) + + # Store observables with stable key + PANEL_MESH_OBSERVABLES[][(body_id, panel_idx)] = ( + vertices = vertices_obs, + border = border_obs, + faces = faces_obs + ) + end + + return fig +end + +""" + save_plot(fig, save_path, title; data_type=".png") + +Save a Makie figure to a file. + +# Arguments +- `fig`: Makie Figure object +- `save_path`: Path to save the plot +- `title`: Title of the plot + +# Keyword arguments +- `data_type`: File extension (default: ".png", also supports ".jpeg") +""" +function VortexStepMethod.save_plot(fig, save_path, title; data_type=".png") + isnothing(save_path) && throw(ArgumentError("save_path should be provided")) + + !isdir(save_path) && mkpath(save_path) + full_path = joinpath(save_path, title * data_type) + + @debug "Attempting to save figure to: $full_path" + @debug "Current working directory: $(pwd())" + + try + save(full_path, fig) + @debug "Figure saved as $data_type" + + if isfile(full_path) + @debug "File successfully saved to $full_path" + @debug "File size: $(filesize(full_path)) bytes" + else + @info "File does not exist after save attempt: $full_path" + end + catch e + @error "Error saving figure: $e" + @error "Error type: $(typeof(e))" + rethrow(e) + end +end + +""" + show_plot(fig; dpi=130) + +Display a Makie figure. + +# Arguments +- `fig`: Makie Figure object + +# Keyword arguments +- `dpi`: Dots per inch for the figure (default: 130) - currently unused in Makie +""" +function VortexStepMethod.show_plot(fig; dpi=130) + display(fig) +end + +""" + plot_line_segment_makie!(ax, segment, color, label; width=3) + +Plot a line segment in 3D with arrow using Makie. + +# Arguments +- `ax`: Makie Axis3 +- `segment`: Array of two points defining the segment +- `color`: Color of the segment +- `label`: Label for the legend + +# Keyword Arguments +- `width`: Line width (default: 3) +""" +function plot_line_segment_makie!(ax, segment, color, label; width=3) + # Plot line + lines!(ax, [Point3f(segment[1]), Point3f(segment[2])]; + color=color, linewidth=width, label=label) + + # Plot arrow + dir = segment[2] - segment[1] + arrows3d!(ax, [Point3f(segment[1])], [Point3f(dir)]; + color=color, shaftradius=0.01, tipradius=0.03, tiplength=0.1) +end + +""" + set_axes_equal_makie!(ax, panels; zoom=1.8) + +Set 3D Makie axis to equal scale based on panel data. + +# Arguments +- `ax`: Makie Axis3 +- `panels`: Array of panels +- `zoom`: zoom factor (default: 1.8) +""" +function set_axes_equal_makie!(ax, panels; zoom=1.8) + # Calculate bounds from all panels + all_x = Float64[] + all_y = Float64[] + all_z = Float64[] + + for panel in panels + for i in 1:4 + push!(all_x, panel.corner_points[1, i]) + push!(all_y, panel.corner_points[2, i]) + push!(all_z, panel.corner_points[3, i]) + end + end + + x_range = (maximum(all_x) - minimum(all_x)) / zoom + y_range = (maximum(all_y) - minimum(all_y)) / zoom + z_range = (maximum(all_z) - minimum(all_z)) / zoom + + max_range = max(x_range, y_range, z_range) + + x_mid = mean([maximum(all_x), minimum(all_x)]) + y_mid = mean([maximum(all_y), minimum(all_y)]) + z_mid = mean([maximum(all_z), minimum(all_z)]) + + limits!(ax, + x_mid - max_range/2, x_mid + max_range/2, + y_mid - max_range/2, y_mid + max_range/2, + z_mid - max_range/2, z_mid + max_range/2) +end + +""" + create_geometry_plot_makie(body_aero::BodyAerodynamics, title, + view_elevation, view_azimuth; zoom=1.8) + +Create a 3D Makie plot of wing geometry including panels and filaments. + +# Arguments +- `body_aero`: struct of type BodyAerodynamics +- `title`: plot title +- `view_elevation`: initial view elevation angle [°] +- `view_azimuth`: initial view azimuth angle [°] + +# Keyword arguments +- `zoom`: zoom factor (default: 1.8) +""" +function create_geometry_plot_makie(body_aero::BodyAerodynamics, title, + view_elevation, view_azimuth; zoom=0.5) + panels = body_aero.panels + va = isa(body_aero.va, Tuple) ? body_aero.va[1] : body_aero.va + + # Create figure + fig = Figure(size=(1400, 1400)) + ax = Axis3(fig[1, 1]; + title=title, + xlabel="x", ylabel="y", zlabel="z", + aspect=:data, + azimuth=deg2rad(view_azimuth), + elevation=deg2rad(view_elevation)) + + # Plot panels + legend_used = Dict{String,Bool}() + for (i, panel) in enumerate(panels) + # Panel edges + corners = [Point3f(panel.corner_points[:, j]) for j in 1:4] + push!(corners, corners[1]) + lines!(ax, corners; color=:grey, linewidth=1, + label = i == 1 ? "Panel Edges" : nothing) + + # Control points + scatter!(ax, [Point3f(panel.control_point)]; + color=:green, markersize=10, + label = i == 1 ? "Control Points" : nothing) + + # Aerodynamic centers + scatter!(ax, [Point3f(panel.aero_center)]; + color=:blue, markersize=10, + label = i == 1 ? "Aerodynamic Centers" : nothing) + + # Plot filaments + filaments = calculate_filaments_for_plotting(panel) + legends = ["Bound Vortex", "side1", "side2", "wake_1", "wake_2"] + + for (filament, legend) in zip(filaments, legends) + x1, x2, color = filament + show_legend = !get(legend_used, legend, false) + plot_line_segment_makie!(ax, [x1, x2], color, + show_legend ? legend : nothing) + legend_used[legend] = true + end + end + + # Plot velocity vector + max_chord = maximum(panel.chord for panel in panels) + va_mag = norm(va) + va_vector_begin = -2 * max_chord * va / va_mag + va_vector_end = va_vector_begin + 1.5 * va / va_mag + plot_line_segment_makie!(ax, [va_vector_begin, va_vector_end], :lightblue, "va") + + # Set equal axes + set_axes_equal_makie!(ax, panels; zoom) + + # Add legend + axislegend(ax; position=:lt) + + return fig +end + +""" + plot_geometry(body_aero::BodyAerodynamics, title; + data_type=".png", save_path=nothing, + is_save=false, is_show=false, + view_elevation=15, view_azimuth=-120, use_tex=false) + +Plot wing geometry from different viewpoints using Makie. + +# Arguments: +- `body_aero`: the BodyAerodynamics to plot +- `title`: plot title + +# Keyword arguments: +- `data_type`: File extension (default: ".png", also supports ".jpeg") +- `save_path`: Path for saving (default: nothing) +- `is_save`: Whether to save (default: false) +- `is_show`: Whether to display (default: false) +- `view_elevation`: View elevation angle [°] (default: 15) +- `view_azimuth`: View azimuth angle [°] (default: -120) +- `use_tex`: Ignored for Makie (default: false) +""" +function VortexStepMethod.plot_geometry(body_aero::BodyAerodynamics, title; + data_type=".png", + save_path=nothing, + is_save=false, + is_show=false, + view_elevation=15, + view_azimuth=-120, + use_tex=false) + + if is_save + # Angled view + fig = create_geometry_plot_makie(body_aero, "$(title)_angled_view", 15, -120) + save_plot(fig, save_path, "$(title)_angled_view", data_type=data_type) + + # Top view + fig = create_geometry_plot_makie(body_aero, "$(title)_top_view", 90, 0) + save_plot(fig, save_path, "$(title)_top_view", data_type=data_type) + + # Front view + fig = create_geometry_plot_makie(body_aero, "$(title)_front_view", 0, 0) + save_plot(fig, save_path, "$(title)_front_view", data_type=data_type) + + # Side view + fig = create_geometry_plot_makie(body_aero, "$(title)_side_view", 0, -90) + save_plot(fig, save_path, "$(title)_side_view", data_type=data_type) + end + + fig = create_geometry_plot_makie(body_aero, title, view_elevation, view_azimuth) + + if is_show + display(fig) + end + + return fig +end + +""" + plot_distribution(y_coordinates_list, results_list, label_list; + title="spanwise_distribution", data_type=".png", + save_path=nothing, is_save=false, is_show=true, use_tex=false) + +Plot spanwise distributions of aerodynamic properties using Makie. + +# Arguments +- `y_coordinates_list`: List of spanwise coordinates +- `results_list`: List of result dictionaries +- `label_list`: List of labels for different results + +# Keyword arguments +- `title`: Plot title (default: "spanwise_distribution") +- `data_type`: File extension (default: ".png", also supports ".jpeg") +- `save_path`: Path to save plots (default: nothing) +- `is_save`: Whether to save (default: false) +- `is_show`: Whether to display (default: true) +- `use_tex`: Ignored for Makie (default: false) +""" +function VortexStepMethod.plot_distribution(y_coordinates_list, results_list, label_list; + title="spanwise_distribution", + data_type=".png", + save_path=nothing, + is_save=false, + is_show=true, + use_tex=false) + + length(results_list) == length(label_list) || throw(ArgumentError( + "Number of results ($(length(results_list))) must match labels ($(length(label_list)))" + )) + + # Create figure with 3x3 grid + fig = Figure(size=(1600, 1000)) + Label(fig[0, :], title, fontsize=20) + + # Row 1: CL, CD, Gamma + ax_cl = Axis(fig[1, 1], title="CL Distribution", + xlabel="Spanwise Position y/b", ylabel="Lift Coefficient CL") + ax_cd = Axis(fig[1, 2], title="CD Distribution", + xlabel="Spanwise Position y/b", ylabel="Drag Coefficient CD") + ax_gamma = Axis(fig[1, 3], title="Γ Distribution", + xlabel="Spanwise Position y/b", ylabel="Circulation Γ") + + # Row 2: Alpha geometric, alpha at ac, alpha uncorrected + ax_alpha_geo = Axis(fig[2, 1], title="α Geometric", + xlabel="Spanwise Position y/b", ylabel="Angle of Attack α (deg)") + ax_alpha_ac = Axis(fig[2, 2], title="α result (corrected to aerodynamic center)", + xlabel="Spanwise Position y/b", ylabel="Angle of Attack α (deg)") + ax_alpha_unc = Axis(fig[2, 3], title="α Uncorrected (if VSM, at control point)", + xlabel="Spanwise Position y/b", ylabel="Angle of Attack α (deg)") + + # Row 3: Force components + ax_fx = Axis(fig[3, 1], title="Force in x direction", + xlabel="Spanwise Position y/b", ylabel="Fx") + ax_fy = Axis(fig[3, 2], title="Force in y direction", + xlabel="Spanwise Position y/b", ylabel="Fy") + ax_fz = Axis(fig[3, 3], title="Force in z direction", + xlabel="Spanwise Position y/b", ylabel="Fz") + + # Plot CL + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + value = round(results["cl"], digits=2) + lines!(ax_cl, Vector(y_coords), Vector(results["cl_distribution"]), + label="$label CL: $value") + end + axislegend(ax_cl, position=:lt) + + # Plot CD + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + value = round(results["cd"], digits=2) + lines!(ax_cd, Vector(y_coords), Vector(results["cd_distribution"]), + label="$label CD: $value") + end + axislegend(ax_cd, position=:lt) + + # Plot Gamma + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + lines!(ax_gamma, Vector(y_coords), Vector(results["gamma_distribution"]), + label=label) + end + axislegend(ax_gamma, position=:lt) + + # Plot alpha geometric + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + lines!(ax_alpha_geo, Vector(y_coords), Vector(results["alpha_geometric"]), + label=label) + end + axislegend(ax_alpha_geo, position=:lt) + + # Plot alpha at ac + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + lines!(ax_alpha_ac, Vector(y_coords), Vector(results["alpha_at_ac"]), + label=label) + end + axislegend(ax_alpha_ac, position=:lt) + + # Plot alpha uncorrected + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + lines!(ax_alpha_unc, Vector(y_coords), Vector(results["alpha_uncorrected"]), + label=label) + end + axislegend(ax_alpha_unc, position=:lt) + + # Plot force components + force_axes = [ax_fx, ax_fy, ax_fz] + components = ["x", "y", "z"] + for (idx, (ax, comp)) in enumerate(zip(force_axes, components)) + for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) + forces = results["F_distribution"][idx, :] + if length(y_coords) != length(forces) + @warn "Dimension mismatch" length(y_coords) length(forces) comp + continue + end + total_force = round(results["F$comp"], digits=2) + lines!(ax, Vector(y_coords), Vector(forces), + label="$label ΣF$comp: $total_force N") + end + axislegend(ax, position=:lt) + end + + # Save and show + if is_save + save_plot(fig, save_path, title, data_type=data_type) + end + + if is_show + display(fig) + end + + return fig +end + +""" + generate_polar_data(solver, body_aero::BodyAerodynamics, angle_range; + angle_type="angle_of_attack", angle_of_attack=0.0, + side_slip=0.0, v_a=10.0) + +Generate polar data for aerodynamic analysis over a range of angles. + +# Arguments +- `solver`: Aerodynamic solver object +- `body_aero`: Wing aerodynamics struct +- `angle_range`: Range of angles to analyze + +# Keyword arguments +- `angle_type`: Type of angle variation ("angle_of_attack" or "side_slip") +- `angle_of_attack`: Initial angle of attack [rad] +- `side_slip`: Initial side slip angle [rad] +- `v_a`: norm of apparent wind speed [m/s] + +# Returns +- Tuple of polar data array and Reynolds number +""" +function generate_polar_data_makie( + solver, + body_aero::BodyAerodynamics, + angle_range; + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0 +) + n_panels = length(body_aero.panels) + n_angles = length(angle_range) + + # Initialize arrays + cl = zeros(n_angles) + cd = zeros(n_angles) + cs = zeros(n_angles) + gamma_distribution = zeros(n_angles, n_panels) + reynolds_number = zeros(n_angles) + + for (i, angle_i) in enumerate(angle_range) + # Set angle based on type + if angle_type == "angle_of_attack" + α = deg2rad(angle_i) + β = side_slip + elseif angle_type == "side_slip" + α = angle_of_attack + β = deg2rad(angle_i) + else + throw(ArgumentError("angle_type must be 'angle_of_attack' or 'side_slip'")) + end + + # Update inflow conditions + set_va!( + body_aero, + [ + cos(α) * cos(β), + sin(β), + sin(α) + ] * v_a + ) + + # Solve and store results + results = solve(solver, body_aero, gamma_distribution[i, :]) + + cl[i] = results["cl"] + cd[i] = results["cd"] + cs[i] = results["cs"] + gamma_distribution[i, :] = results["gamma_distribution"] + reynolds_number[i] = results["Rey"] + end + + polar_data = [angle_range, cl, cd, cs] + return polar_data, reynolds_number[1] +end + +""" + plot_polars(solver_list, body_aero_list, label_list; + literature_path_list=String[], + angle_range=range(0, 20, 2), angle_type="angle_of_attack", + angle_of_attack=0.0, side_slip=0.0, v_a=10.0, + title="polar", data_type=".png", save_path=nothing, + is_save=true, is_show=true, use_tex=false) + +Plot polar data comparing different solvers using Makie. + +# Arguments +- `solver_list`: List of aerodynamic solvers +- `body_aero_list`: List of wing aerodynamics objects +- `label_list`: List of labels for each configuration + +# Keyword arguments +- `literature_path_list`: Optional paths to literature data files +- `angle_range`: Range of angles [°] +- `angle_type`: "angle_of_attack" or "side_slip" (default: angle_of_attack) +- `angle_of_attack`: AoA [rad] (default: 0.0) +- `side_slip`: Side slip angle [rad] (default: 0.0) +- `v_a`: Wind speed [m/s] (default: 10.0) +- `title`: Plot title +- `data_type`: File extension (default: ".png", also supports ".jpeg") +- `save_path`: Path to save (default: nothing) +- `is_save`: Whether to save (default: true) +- `is_show`: Whether to display (default: true) +- `use_tex`: Ignored for Makie (default: false) +""" +function VortexStepMethod.plot_polars( + solver_list, + body_aero_list, + label_list; + literature_path_list=String[], + angle_range=range(0, 20, 2), + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0, + title="polar", + data_type=".png", + save_path=nothing, + is_save=true, + is_show=true, + use_tex=false +) + # Validate inputs + total_cases = length(body_aero_list) + length(literature_path_list) + if total_cases != length(label_list) || length(solver_list) != length(body_aero_list) + throw(ArgumentError("Mismatch in solvers/cases/labels")) + end + main_title = replace(title, " " => "_") + + # Generate polar data + polar_data_list = [] + labels_with_re = copy(label_list) + for (i, (solver, body_aero)) in enumerate(zip(solver_list, body_aero_list)) + polar_data, rey = generate_polar_data_makie( + solver, body_aero, angle_range; + angle_type, angle_of_attack, side_slip, v_a + ) + push!(polar_data_list, polar_data) + labels_with_re[i] = "$(label_list[i]) Re = $(round(Int64, rey*1e-5))e5" + end + + # Load literature data if provided + if !isempty(literature_path_list) + for path in literature_path_list + data = readdlm(path, ',') + header = lowercase.(string.(data[1, :])) + alpha_idx = findfirst(x -> occursin("alpha", x), header) + cl_idx = findfirst(x -> occursin("cl", x), header) + cd_idx = findfirst(x -> occursin("cd", x), header) + cs_idx = findfirst(x -> occursin("cs", x), header) + cs_col = cs_idx === nothing ? zeros(size(data, 1)-1) : data[2:end, cs_idx] + push!(polar_data_list, [ + data[2:end, alpha_idx], + data[2:end, cl_idx], + data[2:end, cd_idx], + cs_col + ]) + end + end + + # Create figure with 2x2 grid + fig = Figure(size=(1400, 1400)) + + ax_cl = Axis(fig[1, 1], title="CL vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CL") + ax_cd = Axis(fig[1, 2], title="CD vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CD") + ax_cs = Axis(fig[2, 1], title="CS vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CS") + ax_polar = Axis(fig[2, 2], title="CL vs CD", + xlabel="CD", ylabel="CL") + + # Number of computational results + n_solvers = length(solver_list) + + # Plot CL vs angle + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, labels_with_re)) + marker = i <= n_solvers ? :star5 : :circle + markersize = i <= n_solvers ? 12 : 8 + scatterlines!(ax_cl, polar_data[1], polar_data[2]; + label=label, marker=marker, markersize=markersize) + if maximum(polar_data[2]) > 10 + ylims!(ax_cl, -0.5, 2) + end + end + axislegend(ax_cl, position=:lt) + + # Plot CD vs angle + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, labels_with_re)) + marker = i <= n_solvers ? :star5 : :circle + markersize = i <= n_solvers ? 12 : 8 + scatterlines!(ax_cd, polar_data[1], polar_data[3]; + label=label, marker=marker, markersize=markersize) + if maximum(polar_data[2]) > 10 + ylims!(ax_cd, -0.5, 2) + end + end + axislegend(ax_cd, position=:lt) + + # Plot CS vs angle + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, labels_with_re)) + marker = i <= n_solvers ? :star5 : :circle + markersize = i <= n_solvers ? 12 : 8 + scatterlines!(ax_cs, polar_data[1], polar_data[4]; + label=label, marker=marker, markersize=markersize) + if maximum(polar_data[2]) > 10 + ylims!(ax_cs, -0.5, 2) + end + end + axislegend(ax_cs, position=:lt) + + # Plot CL vs CD + for (i, (polar_data, label)) in enumerate(zip(polar_data_list, labels_with_re)) + marker = i <= n_solvers ? :star5 : :circle + markersize = i <= n_solvers ? 12 : 8 + scatterlines!(ax_polar, polar_data[3], polar_data[2]; + label=label, marker=marker, markersize=markersize) + if maximum(polar_data[2]) > 10 || maximum(polar_data[3]) > 10 + ylims!(ax_polar, -0.5, 2) + xlims!(ax_polar, -0.5, 2) + end + end + axislegend(ax_polar, position=:lt) + + # Save and show + if is_save && !isnothing(save_path) + save_plot(fig, save_path, main_title; data_type) + end + + if is_show + display(fig) + end + + return fig +end + +""" + plot_polar_data(body_aero::BodyAerodynamics; + alphas=collect(deg2rad.(-5:0.3:25)), + delta_tes=collect(deg2rad.(-5:0.3:25)), + is_show=true, use_tex=false) + +Plot polar data (Cl, Cd, Cm) as 3D surfaces using Makie. + +# Arguments +- `body_aero`: Wing aerodynamics struct + +# Keyword arguments +- `alphas`: Range of AoA values [rad] (default: -5° to 25° in 0.3° steps) +- `delta_tes`: Range of trailing edge angles [rad] (default: -5° to 25° in 0.3° steps) +- `is_show`: Whether to display (default: true) +- `use_tex`: Ignored for Makie (default: false) +""" +function VortexStepMethod.plot_polar_data(body_aero::BodyAerodynamics; + alphas=collect(deg2rad.(-5:0.3:25)), + delta_tes=collect(deg2rad.(-5:0.3:25)), + is_show=true, + use_tex=false) + + if body_aero.panels[1].aero_model == POLAR_MATRICES + # Create figure with 3 subplots + fig = Figure(size=(1500, 600)) + + # Get interpolation functions + interp_data = [ + (body_aero.panels[1].cl_interp, "Cl"), + (body_aero.panels[1].cd_interp, "Cd"), + (body_aero.panels[1].cm_interp, "Cm") + ] + + # Create each subplot + for (idx, (interp, label)) in enumerate(interp_data) + ax = Axis3(fig[1, idx]; + title="$label vs α and δ", + xlabel="δ [rad]", + ylabel="α [rad]", + zlabel=label, + azimuth=1.275*π) + + # Create interpolation matrix + interp_matrix = [interp(alpha, delta_te) + for alpha in alphas, delta_te in delta_tes] + + # Create wireframe + wireframe!(ax, delta_tes, alphas, interp_matrix; + color=:blue, linewidth=0.5, transparency=true) + end + + if is_show + display(fig) + end + return fig + else + throw(ArgumentError( + "Plotting polar data for $(body_aero.panels[1].aero_model) not implemented." + )) + end +end + +""" + plot_combined_analysis(solver, body_aero, results; + solver_label="VSM", + angle_range=range(0,20,length=20), + angle_type="angle_of_attack", + angle_of_attack=0.0, side_slip=0.0, v_a=10.0, + title="Combined Analysis", + view_elevation=15, view_azimuth=-120, + is_show=true, use_tex=false, + literature_path_list=String[], + data_type=".png", save_path=nothing, is_save=false) + +Create combined multi-panel figure with geometry, polar data, distributions, and polars. + +# Arguments +- `solver`: Aerodynamic solver +- `body_aero`: BodyAerodynamics object +- `results`: Solution dictionary from solve() + +# Keyword arguments +- `solver_label`: Label for solver (default: "VSM") +- `angle_range`: Range of angles for polars (default: range(0,20,length=20)) +- `angle_type`: "angle_of_attack" or "side_slip" (default: "angle_of_attack") +- `angle_of_attack`: AoA in degrees (default: 0.0) +- `side_slip`: Side slip in degrees (default: 0.0) +- `v_a`: Wind speed in m/s (default: 10.0) +- `title`: Overall figure title (default: "Combined Analysis") +- `view_elevation`: Geometry view elevation [°] (default: 15) +- `view_azimuth`: Geometry view azimuth [°] (default: -120) +- `is_show`: Display figure (default: true) +- `use_tex`: Ignored for Makie (default: false) +- `literature_path_list`: Paths to literature CSV files (default: String[]) +- `data_type`: File extension (default: ".png", also supports ".jpeg") +- `save_path`: Directory path to save files (default: nothing) +- `is_save`: Save plots to files (default: false) +""" +function VortexStepMethod.plot_combined_analysis( + solver, + body_aero::BodyAerodynamics, + results::Dict; + solver_label="VSM", + angle_range=range(0, 20, length=20), + angle_type="angle_of_attack", + angle_of_attack=0.0, + side_slip=0.0, + v_a=10.0, + title="Combined Analysis", + view_elevation=15, + view_azimuth=-120, + is_show=true, + use_tex=false, + literature_path_list=String[], + data_type=".png", + save_path=nothing, + is_save=false +) + # Auto-detect screen size and use 80% of it + fig = try + screen_size = Makie.primary_resolution() + fig_width = round(Int, screen_size[1] * 0.8) + fig_height = round(Int, screen_size[2] * 0.8) + Figure(size=(fig_width, fig_height)) + catch + # Fallback if screen detection fails + Figure(size=(1800, 1200)) + end + Label(fig[0, :], title, fontsize=20, font=:bold) + + panels = body_aero.panels + va = isa(body_aero.va, Tuple) ? body_aero.va[1] : body_aero.va + + # [1,1] Wing Geometry + ax_geo = Axis3(fig[1, 1]; + title="Wing Geometry", + xlabel="x", ylabel="y", zlabel="z", + aspect=:data, + azimuth=deg2rad(view_azimuth), + elevation=deg2rad(view_elevation)) + + legend_used = Dict{String,Bool}() + for (i, panel) in enumerate(panels) + corners = [Point3f(panel.corner_points[:, j]) for j in 1:4] + push!(corners, corners[1]) + lines!(ax_geo, corners; color=:grey, linewidth=1, + label = i == 1 ? "Panel Edges" : nothing) + + scatter!(ax_geo, [Point3f(panel.control_point)]; + color=:green, markersize=10, + label = i == 1 ? "Control Points" : nothing) + + scatter!(ax_geo, [Point3f(panel.aero_center)]; + color=:blue, markersize=10, + label = i == 1 ? "Aerodynamic Centers" : nothing) + + filaments = calculate_filaments_for_plotting(panel) + legends = ["Bound Vortex", "side1", "side2", "wake_1", "wake_2"] + + for (filament, legend) in zip(filaments, legends) + x1, x2, color = filament + show_legend = !get(legend_used, legend, false) + plot_line_segment_makie!(ax_geo, [x1, x2], color, + show_legend ? legend : nothing) + legend_used[legend] = true + end + end + + max_chord = maximum(panel.chord for panel in panels) + va_mag = norm(va) + va_vector_begin = -2 * max_chord * va / va_mag + va_vector_end = va_vector_begin + 1.5 * va / va_mag + plot_line_segment_makie!(ax_geo, [va_vector_begin, va_vector_end], + :lightblue, "va") + + set_axes_equal_makie!(ax_geo, panels; zoom=0.5) + axislegend(ax_geo; position=:lt) + + # [1,2] Polar Data Surfaces or Curves + if body_aero.panels[1].aero_model == POLAR_MATRICES + alphas = collect(deg2rad.(-5:0.3:25)) + delta_tes = collect(deg2rad.(-5:0.3:25)) + + interp_data = [ + (body_aero.panels[1].cl_interp, "Cl"), + (body_aero.panels[1].cd_interp, "Cd"), + (body_aero.panels[1].cm_interp, "Cm") + ] + + for (idx, (interp, label)) in enumerate(interp_data) + ax = Axis3(fig[1, 2][1, idx]; + title="$label vs α and δ", + xlabel="δ [rad]", + ylabel="α [rad]", + zlabel=label, + azimuth=1.275*π) + + interp_matrix = [interp(alpha, delta_te) + for alpha in alphas, delta_te in delta_tes] + + wireframe!(ax, delta_tes, alphas, interp_matrix; + color=:blue, linewidth=0.5, transparency=true) + end + elseif body_aero.panels[1].aero_model == POLAR_VECTORS + alphas_deg = collect(-5:0.5:25) + alphas = deg2rad.(alphas_deg) + + ax_cl_curve = Axis(fig[1, 2][1, 1]; + title="Cl vs α", + xlabel="α [°]", + ylabel="Cl") + ax_cd_curve = Axis(fig[1, 2][1, 2]; + title="Cd vs α", + xlabel="α [°]", + ylabel="Cd") + ax_cm_curve = Axis(fig[1, 2][1, 3]; + title="Cm vs α", + xlabel="α [°]", + ylabel="Cm") + + cl_vals = [body_aero.panels[1].cl_interp(a) for a in alphas] + cd_vals = [body_aero.panels[1].cd_interp(a) for a in alphas] + cm_vals = [body_aero.panels[1].cm_interp(a) for a in alphas] + + lines!(ax_cl_curve, alphas_deg, cl_vals; color=:blue, linewidth=2) + lines!(ax_cd_curve, alphas_deg, cd_vals; color=:red, linewidth=2) + lines!(ax_cm_curve, alphas_deg, cm_vals; color=:green, linewidth=2) + end + + # [2,1] Spanwise Distributions (3×3 grid) + y_coords = [panel.aero_center[2] for panel in body_aero.panels] + + ax_cl = Axis(fig[2, 1][1, 1], title="CL Distribution", + xlabel="Spanwise Position y/b", ylabel="CL") + ax_cd = Axis(fig[2, 1][1, 2], title="CD Distribution", + xlabel="Spanwise Position y/b", ylabel="CD") + ax_gamma = Axis(fig[2, 1][1, 3], title="Γ Distribution", + xlabel="Spanwise Position y/b", ylabel="Γ") + + ax_alpha_geo = Axis(fig[2, 1][2, 1], title="α Geometric", + xlabel="Spanwise Position y/b", ylabel="α (deg)") + ax_alpha_ac = Axis(fig[2, 1][2, 2], title="α at aero center", + xlabel="Spanwise Position y/b", ylabel="α (deg)") + ax_alpha_unc = Axis(fig[2, 1][2, 3], title="α Uncorrected", + xlabel="Spanwise Position y/b", ylabel="α (deg)") + + ax_fx = Axis(fig[2, 1][3, 1], title="Force x", + xlabel="Spanwise Position y/b", ylabel="Fx") + ax_fy = Axis(fig[2, 1][3, 2], title="Force y", + xlabel="Spanwise Position y/b", ylabel="Fy") + ax_fz = Axis(fig[2, 1][3, 3], title="Force z", + xlabel="Spanwise Position y/b", ylabel="Fz") + + cl_val = round(results["cl"], digits=2) + lines!(ax_cl, Vector(y_coords), Vector(results["cl_distribution"]), + label="$solver_label CL: $cl_val") + axislegend(ax_cl, position=:lt) + + cd_val = round(results["cd"], digits=2) + lines!(ax_cd, Vector(y_coords), Vector(results["cd_distribution"]), + label="$solver_label CD: $cd_val") + axislegend(ax_cd, position=:lt) + + lines!(ax_gamma, Vector(y_coords), Vector(results["gamma_distribution"]), + label=solver_label) + axislegend(ax_gamma, position=:lt) + + lines!(ax_alpha_geo, Vector(y_coords), Vector(results["alpha_geometric"]), + label=solver_label) + axislegend(ax_alpha_geo, position=:lt) + + lines!(ax_alpha_ac, Vector(y_coords), Vector(results["alpha_at_ac"]), + label=solver_label) + axislegend(ax_alpha_ac, position=:lt) + + lines!(ax_alpha_unc, Vector(y_coords), + Vector(results["alpha_uncorrected"]), label=solver_label) + axislegend(ax_alpha_unc, position=:lt) + + force_axes = [ax_fx, ax_fy, ax_fz] + components = ["x", "y", "z"] + for (idx, (ax, comp)) in enumerate(zip(force_axes, components)) + forces = results["F_distribution"][idx, :] + total_force = round(results["F$comp"], digits=2) + lines!(ax, Vector(y_coords), Vector(forces), + label="$solver_label ΣF$comp: $total_force N") + axislegend(ax, position=:lt) + end + + # [2,2] Polars (2×2 grid) + polar_data, rey = generate_polar_data_makie( + solver, body_aero, angle_range; + angle_type, angle_of_attack=deg2rad(angle_of_attack), + side_slip=deg2rad(side_slip), v_a + ) + + label_with_re = "$solver_label Re = $(round(Int64, rey*1e-5))e5" + + ax_cl_polar = Axis(fig[2, 2][1, 1], title="CL vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CL") + ax_cd_polar = Axis(fig[2, 2][1, 2], title="CD vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CD") + ax_cs_polar = Axis(fig[2, 2][2, 1], title="CS vs $angle_type [°]", + xlabel="$angle_type [°]", ylabel="CS") + ax_polar = Axis(fig[2, 2][2, 2], title="CL vs CD", + xlabel="CD", ylabel="CL") + + scatterlines!(ax_cl_polar, polar_data[1], polar_data[2]; + label=label_with_re, marker=:star5, markersize=12) + axislegend(ax_cl_polar, position=:lt) + + scatterlines!(ax_cd_polar, polar_data[1], polar_data[3]; + label=label_with_re, marker=:star5, markersize=12) + axislegend(ax_cd_polar, position=:lt) + + scatterlines!(ax_cs_polar, polar_data[1], polar_data[4]; + label=label_with_re, marker=:star5, markersize=12) + axislegend(ax_cs_polar, position=:lt) + + scatterlines!(ax_polar, polar_data[3], polar_data[2]; + label=label_with_re, marker=:star5, markersize=12) + axislegend(ax_polar, position=:lt) + + # Set column widths: left column wider for 3x3 grid + colsize!(fig.layout, 1, Relative(0.6)) + colsize!(fig.layout, 2, Relative(0.4)) + + if is_show + display(fig) + end + return fig end diff --git a/scripts/Project.toml b/scripts/Project.toml new file mode 100644 index 00000000..f020312d --- /dev/null +++ b/scripts/Project.toml @@ -0,0 +1,4 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589" +VortexStepMethod = "ed3cd733-9f0f-46a9-93e0-89b8d4998dd9" diff --git a/scripts/build_docu.jl b/scripts/build_docu.jl index f88f437c..f9398642 100644 --- a/scripts/build_docu.jl +++ b/scripts/build_docu.jl @@ -17,8 +17,4 @@ if !("LiveServer" in globaldependencies()) run(`julia -e 'using Pkg; Pkg.add("LiveServer")'`) end -if !("Documenter" ∈ keys(Pkg.project().dependencies)) - using TestEnv - TestEnv.activate() -end using LiveServer; servedocs(launch_browser=true) diff --git a/src/VortexStepMethod.jl b/src/VortexStepMethod.jl index c852c48e..9a3c7462 100644 --- a/src/VortexStepMethod.jl +++ b/src/VortexStepMethod.jl @@ -27,7 +27,7 @@ using Xfoil # Export public interface export VSMSettings, WingSettings, SolverSettings -export Wing, Section, RamAirWing, reinit! +export Wing, Section, ObjWing, reinit!, refine! export BodyAerodynamics export Solver, solve, solve_base!, solve!, VSMSolution, linearize export calculate_results @@ -42,7 +42,8 @@ export SolverStatus, FEASIBLE, INFEASIBLE, FAILURE export SolverType, LOOP, NONLIN export load_polar_data -export plot_geometry, plot_distribution, plot_circulation_distribution, plot_polars, save_plot, show_plot, plot_polar_data +export plot_geometry, plot_distribution, plot_circulation_distribution, plot_polars, + save_plot, show_plot, plot_polar_data, plot_combined_analysis # the following functions are defined in ext/VortexStepMethodExt.jl function plot_geometry end @@ -52,6 +53,7 @@ function plot_polars end function save_plot end function show_plot end function plot_polar_data end +function plot_combined_analysis end """ const MVec3 = MVector{3, Float64} @@ -128,14 +130,14 @@ Enumeration of the implemented panel distributions. - COSINE # Cosine distribution - `COSINE_VAN_GARREL` # van Garrel cosine distribution - `SPLIT_PROVIDED` # Split provided sections -- UNCHANGED # Keep original sections +- `UNCHANGED` # 1:1 copy of unrefined to refined sections (no interpolation) """ @enum PanelDistribution begin LINEAR # Linear distribution COSINE # Cosine distribution COSINE_VAN_GARREL # van Garrel cosine distribution SPLIT_PROVIDED # Split provided sections - UNCHANGED # Keep original sections + UNCHANGED # 1:1 copy of unrefined to refined sections end """ @@ -272,7 +274,7 @@ end include("settings.jl") include("wing_geometry.jl") include("polars.jl") -include("ram_geometry.jl") +include("obj_geometry.jl") include("yaml_geometry.jl") include("filament.jl") include("panel.jl") @@ -282,4 +284,4 @@ include("solver.jl") include("precompile.jl") -end # module \ No newline at end of file +end # module diff --git a/src/body_aerodynamics.jl b/src/body_aerodynamics.jl index 87e75fec..b40be910 100644 --- a/src/body_aerodynamics.jl +++ b/src/body_aerodynamics.jl @@ -4,17 +4,17 @@ Main structure for calculating aerodynamic properties of bodies. Use the constructor to initialize. # Fields -- panels::Vector{Panel}: Vector of [Panel](@ref) structs -- wings::Union{Vector{Wing}, Vector{RamAirWing}}: A vector of wings; a body can have multiple wings +- panels::Vector{Panel}: Vector of refined [Panel](@ref) structs +- wings::Vector{Wing}: A vector of wings; a body can have multiple wings - `va::MVec3` = zeros(MVec3): A vector of the apparent wind speed, see: [MVec3](@ref) - `omega`::MVec3 = zeros(MVec3): A vector of the turn rates around the kite body axes -- `gamma_distribution`=zeros(Float64, P): A vector of the circulation +- `gamma_distribution`=zeros(Float64, P): A vector of the circulation of the velocity field; Length: Number of segments. [m²/s] - `alpha_uncorrected`=zeros(Float64, P): angles of attack per panel - `alpha_corrected`=zeros(Float64, P): corrected angles of attack per panel - `stall_angle_list`=zeros(Float64, P): stall angle per panel -- `alpha_array::MVector{P, Float64}` = zeros(Float64, P) -- `v_a_array::MVector{P, Float64}` = zeros(Float64, P) +- `alpha_dist::MVector{P, Float64}` = zeros(Float64, P) +- `v_a_dist::MVector{P, Float64}` = zeros(Float64, P) - `work_vectors`::NTuple{10, MVec3} = ntuple(_ -> zeros(MVec3), 10) - `AIC::Array{Float64, 3}` = zeros(3, P, P) - `projected_area::Float64` = 1.0: The area projected onto the xy-plane of the kite body reference frame [m²] @@ -23,15 +23,15 @@ Main structure for calculating aerodynamic properties of bodies. Use the constru """ @with_kw mutable struct BodyAerodynamics{P} panels::Vector{Panel} - wings::Union{Vector{Wing}, Vector{RamAirWing}} + wings::Vector{Wing} _va::MVec3 = zeros(MVec3) omega::MVec3 = zeros(MVec3) gamma_distribution::MVector{P, Float64} = zeros(P) alpha_uncorrected::MVector{P, Float64} = zeros(P) alpha_corrected::MVector{P, Float64} = zeros(P) stall_angle_list::MVector{P, Float64} = zeros(P) - alpha_array::MVector{P, Float64} = zeros(P) - v_a_array::MVector{P, Float64} = zeros(P) + alpha_dist::MVector{P, Float64} = zeros(P) + v_a_dist::MVector{P, Float64} = zeros(P) work_vectors::NTuple{10,MVec3} = ntuple(_ -> zeros(MVec3), 10) AIC::Array{Float64, 3} = zeros(3, P, P) projected_area::Float64 = one(Float64) @@ -71,20 +71,35 @@ function BodyAerodynamics( va=[15.0, 0.0, 0.0], omega=zeros(MVec3) ) where T <: AbstractWing + # Validate all wings are refined + for (i, wing) in enumerate(wings) + if isempty(wing.refined_sections) || + length(wing.refined_sections) != wing.n_panels + 1 + throw(ArgumentError( + "Wing $i has not been refined. " * + "Call refine!(wing) before creating BodyAerodynamics.\n\n" * + "Expected workflow:\n" * + " wing = Wing(...)\n" * + " refine!(wing)\n" * + " body_aero = BodyAerodynamics([wing])" + )) + end + + if isempty(wing.non_deformed_sections) + @warn "Wing $i has no non_deformed_sections. " * + "Deformation (unrefined_deform!) will not work. " * + "This should have been created by refine!." maxlog=1 + end + end + # Initialize panels panels = Panel[] + n_unrefined_total = 0 for wing in wings - for section in wing.sections + for section in wing.unrefined_sections section.LE_point .-= kite_body_origin section.TE_point .-= kite_body_origin end - if wing.spanwise_distribution == UNCHANGED - wing.refined_sections = wing.sections - !(wing.n_panels == length(wing.sections) - 1) && - throw(ArgumentError("(wing.n_panels = $(wing.n_panels)) != (length(wing.sections) - 1 = $(length(wing.sections) - 1))")) - else - wing.refined_sections = Section[Section() for _ in 1:wing.n_panels+1] - end # Create panels for _ in 1:wing.n_panels @@ -114,7 +129,7 @@ function Base.setproperty!(obj::BodyAerodynamics, sym::Symbol, val) end """ - reinit!(body_aero::BodyAerodynamics; init_aero, va, omega) + reinit!(body_aero::BodyAerodynamics; init_aero, va, omega, refine_mesh, recompute_mapping, sort_sections) Initialize a BodyAerodynamics struct in-place by setting up panels and coefficients. @@ -122,27 +137,28 @@ Initialize a BodyAerodynamics struct in-place by setting up panels and coefficie - `body_aero::BodyAerodynamics`: The structure to initialize # Keyword Arguments -- `init_aero::Bool`: Wether to initialize the aero data or not +- `init_aero::Bool`: Whether to initialize the aero data or not - `va=[15.0, 0.0, 0.0]`: Apparent wind vector - `omega=zeros(3)`: Turn rate in kite body frame x y and z # Returns nothing """ -function reinit!(body_aero::BodyAerodynamics; +function reinit!(body_aero::BodyAerodynamics; init_aero=true, va=[15.0, 0.0, 0.0], omega=zeros(MVec3) ) idx = 1 - vec = zeros(MVec3) + vec = @MVector zeros(3) for wing in body_aero.wings reinit!(wing) panel_props = wing.panel_props # Create panels for i in 1:wing.n_panels - if wing isa RamAirWing + if !isnothing(wing.delta_dist) && length(wing.delta_dist) > 0 + # Panel i gets its delta directly from delta_dist[i] delta = wing.delta_dist[i] else delta = 0.0 @@ -166,12 +182,12 @@ function reinit!(body_aero::BodyAerodynamics; idx += 1 end end - + # Initialize rest of the struct - body_aero.projected_area = sum(wing -> calculate_projected_area(wing), body_aero.wings) - body_aero.stall_angle_list .= calculate_stall_angle_list(body_aero.panels) - body_aero.alpha_array .= 0.0 - body_aero.v_a_array .= 0.0 + body_aero.projected_area = sum(calculate_projected_area, body_aero.wings) + calculate_stall_angle_list!(body_aero.stall_angle_list, body_aero.panels) + body_aero.alpha_dist .= 0.0 + body_aero.v_a_dist .= 0.0 body_aero.AIC .= 0.0 set_va!(body_aero, va, omega) return nothing @@ -191,21 +207,28 @@ Returns: nothing """ @inline function calculate_AIC_matrices!(body_aero::BodyAerodynamics, model::Model, core_radius_fraction, - va_norm_array, + va_norm_array, va_unit_array) # Determine evaluation point based on model evaluation_point = model == VSM ? :control_point : :aero_center evaluation_point_on_bound = model == LLT - - # Initialize AIC matrices - velocity_induced, tempvel, va_unit, U_2D = zeros(MVec3), zeros(MVec3), zeros(MVec3), zeros(MVec3) + + # Allocate work vectors for this function (separate from those used by child functions) + velocity_induced = @MVector zeros(3) + tempvel = @MVector zeros(3) + va_unit = @MVector zeros(3) + U_2D = @MVector zeros(3) # Calculate influence coefficients for icp in eachindex(body_aero.panels) - ep = getproperty(body_aero.panels[icp], evaluation_point) + panel_icp = body_aero.panels[icp] + ep = evaluation_point == :control_point ? panel_icp.control_point : panel_icp.aero_center for jring in eachindex(body_aero.panels) - va_unit .= @views va_unit_array[jring, :] - filaments = body_aero.panels[jring].filaments + panel_jring = body_aero.panels[jring] + @inbounds for k in 1:3 + va_unit[k] = va_unit_array[jring, k] + end + filaments = panel_jring.filaments va_norm = va_norm_array[jring] calculate_velocity_induced_single_ring_semiinfinite!( velocity_induced, @@ -222,8 +245,8 @@ Returns: nothing # Subtract 2D induced velocity for VSM if icp == jring && model == VSM - calculate_velocity_induced_bound_2D!(U_2D, body_aero.panels[jring], ep, body_aero.work_vectors) - velocity_induced .-= U_2D + calculate_velocity_induced_bound_2D!(U_2D, panel_jring, ep, body_aero.work_vectors) + velocity_induced .-= U_2D end body_aero.AIC[:, icp, jring] .= velocity_induced end @@ -276,19 +299,34 @@ function calculate_stall_angle_list(panels::Vector{Panel}; step_aoa=1.0, stall_angle_if_none_detected=50.0, cl_initial=-10.0) - - aoa_range = deg2rad.(range(begin_aoa, end_aoa, step=step_aoa)) - stall_angles = Float64[] - - for panel in panels + stall_angles = Vector{Float64}(undef, length(panels)) + calculate_stall_angle_list!(stall_angles, panels; + begin_aoa, end_aoa, step_aoa, + stall_angle_if_none_detected, cl_initial) + return stall_angles +end + +function calculate_stall_angle_list!(stall_angles::AbstractVector{Float64}, + panels::Vector{Panel}; + begin_aoa=9.0, + end_aoa=22.0, + step_aoa=1.0, + stall_angle_if_none_detected=50.0, + cl_initial=-10.0) + + # Pre-compute range values to avoid allocation + n_steps = Int(floor((end_aoa - begin_aoa) / step_aoa)) + 1 + + for (idx, panel) in enumerate(panels) # Default stall angle if none found panel_stall = stall_angle_if_none_detected - + # Start with minimum cl cl_old = cl_initial - + # Find stall angle - for aoa in aoa_range + for i in 0:(n_steps-1) + aoa = deg2rad(begin_aoa + i * step_aoa) cl = calculate_cl(panel, aoa) if cl < cl_old panel_stall = aoa @@ -296,11 +334,11 @@ function calculate_stall_angle_list(panels::Vector{Panel}; end cl_old = cl end - - push!(stall_angles, panel_stall) + + stall_angles[idx] = panel_stall end - - return stall_angles + + return nothing end """ @@ -380,7 +418,7 @@ end calculate_results(body_aero::BodyAerodynamics, gamma_new, density, aerodynamic_model_type::Model, core_radius_fraction, mu, - alpha_array, v_a_array, + alpha_dist, v_a_dist, chord_array, x_airf_array, y_airf_array, z_airf_array, va_array, va_norm_array, @@ -400,8 +438,8 @@ function calculate_results( aerodynamic_model_type::Model, core_radius_fraction, mu, - alpha_array, - v_a_array, + alpha_dist, + v_a_dist, chord_array, x_airf_array, y_airf_array, @@ -410,7 +448,8 @@ function calculate_results( va_norm_array, va_unit_array, panels::Vector{Panel}, - is_only_f_and_gamma_output::Bool, + is_only_f_and_gamma_output::Bool; + correct_aoa::Bool=false, ) # Initialize arrays @@ -423,18 +462,18 @@ function calculate_results( # Calculate coefficients for each panel for (i, panel) in enumerate(panels) - cl_array[i] = calculate_cl(panel, alpha_array[i]) - cd_array[i], cm_array[i] = calculate_cd_cm(panel, alpha_array[i]) + cl_array[i] = calculate_cl(panel, alpha_dist[i]) + cd_array[i], cm_array[i] = calculate_cd_cm(panel, alpha_dist[i]) panel_width_array[i] = panel.width end # Calculate forces - lift = reshape((cl_array .* 0.5 .* density .* v_a_array.^2 .* chord_array), :, 1) - drag = reshape((cd_array .* 0.5 .* density .* v_a_array.^2 .* chord_array), :, 1) - moment = reshape((cm_array .* 0.5 .* density .* v_a_array.^2 .* chord_array), :, 1) + lift = reshape((cl_array .* 0.5 .* density .* v_a_dist.^2 .* chord_array), :, 1) + drag = reshape((cd_array .* 0.5 .* density .* v_a_dist.^2 .* chord_array), :, 1) + moment = reshape((cm_array .* 0.5 .* density .* v_a_dist.^2 .* chord_array), :, 1) # Calculate alpha corrections based on model type - if aerodynamic_model_type == VSM + if correct_aoa update_effective_angle_of_attack!( alpha_corrected, body_aero, @@ -446,8 +485,8 @@ function calculate_results( va_norm_array, va_unit_array ) - elseif aerodynamic_model_type == LLT - alpha_corrected .= alpha_array + else + alpha_corrected .= alpha_dist end # Verify va is not distributed @@ -593,7 +632,7 @@ function calculate_results( "cfy" => (sum(f_body_3D[2,:]) / (q_inf * projected_area)), "cfz" => (sum(f_body_3D[3,:]) / (q_inf * projected_area)), "alpha_at_ac" => alpha_corrected, - "alpha_uncorrected" => alpha_array, + "alpha_uncorrected" => alpha_dist, "alpha_geometric" => alpha_geometric, "gamma_distribution" => gamma_new, "area_all_panels" => area_all_panels, @@ -622,8 +661,7 @@ Set velocity array and update wake filaments. - `va::VelVector`: Velocity vector of the apparent wind speed [m/s] - `omega::VelVector`: Turn rate vector around x y and z axis [rad/s] """ -function set_va!(body_aero::BodyAerodynamics, va::VelVector, omega=zeros(MVec3)) - +function set_va!(body_aero::BodyAerodynamics, va::AbstractVector, omega=zeros(MVec3)) # Calculate va_distribution based on input type va_distribution = if all(omega .== 0.0) repeat(reshape(va, 1, 3), length(body_aero.panels)) @@ -654,16 +692,17 @@ function set_va!(body_aero::BodyAerodynamics, va::VelVector, omega=zeros(MVec3)) return nothing end -function set_va!(body_aero::BodyAerodynamics, va_distribution::Vector{VelVector}, omega=zeros(MVec3)) - length(va) != length(body_aero.panels) && throw(ArgumentError("Length of va distribution should be equal to number of panels.")) - +function set_va!(body_aero::BodyAerodynamics, va_distribution::AbstractMatrix, omega=zeros(MVec3)) + size(va_distribution, 1) != length(body_aero.panels) && + throw(ArgumentError("Number of rows in va distribution should be equal to number of panels.")) + for (i, panel) in enumerate(body_aero.panels) - panel.va = va_distribution[i] + panel.va .= va_distribution[i, :] end - + # Update wake elements frozen_wake!(body_aero, va_distribution) - body_aero._va = va + body_aero._va .= [mean(va_distribution[:,i]) for i in 1:3] return nothing end diff --git a/src/ram_geometry.jl b/src/obj_geometry.jl similarity index 56% rename from src/ram_geometry.jl rename to src/obj_geometry.jl index e352c4e2..ea9863ca 100644 --- a/src/ram_geometry.jl +++ b/src/obj_geometry.jl @@ -14,7 +14,7 @@ Read vertices and faces from an OBJ file. function read_faces(filename) vertices = [] faces = [] - + open(filename) do file for line in eachline(file) if startswith(line, "v ") && !startswith(line, "vt") && !startswith(line, "vn") @@ -112,43 +112,102 @@ Create interpolation functions for leading/trailing edges and area. - Where le_interp and te_interp are tuples themselves, containing the x, y and z interpolations """ function create_interpolations(vertices, circle_center_z, radius, gamma_tip, R=I(3); interp_steps=40) - gamma_range = range(-gamma_tip+1e-6, gamma_tip-1e-6, interp_steps) + gamma_range = range(-gamma_tip+gamma_tip/interp_steps*2, + gamma_tip-gamma_tip/interp_steps*2, interp_steps) stepsize = gamma_range.step.hi vz_centered = [v[3] - circle_center_z for v in vertices] - + te_gammas = zeros(length(gamma_range)) le_gammas = zeros(length(gamma_range)) trailing_edges = zeros(3, length(gamma_range)) leading_edges = zeros(3, length(gamma_range)) areas = zeros(length(gamma_range)) - + + n_slices = length(gamma_range) for (j, gamma) in enumerate(gamma_range) trailing_edges[1, j] = -Inf leading_edges[1, j] = Inf - for (i, v) in enumerate(vertices) - # Rotate y coordinate to check box containment - # rotated_y = v[2] * cos(-gamma) - vz_centered[i] * sin(-gamma) - gamma_v = atan(-v[2], vz_centered[i]) - if gamma ≤ 0 && gamma - stepsize ≤ gamma_v ≤ gamma - if v[1] > trailing_edges[1, j] - trailing_edges[:, j] .= v - te_gammas[j] = gamma_v - end - if v[1] < leading_edges[1, j] - leading_edges[:, j] .= v - le_gammas[j] = gamma_v + + # Determine if this is a tip slice and get search parameters + is_first_tip = (j == 1) + is_last_tip = (j == n_slices) + + if is_first_tip || is_last_tip + # Tip slices: use directional search within adjacent slice region + gamma_search = is_first_tip ? gamma_range[1] : gamma_range[end] + max_te_score = -Inf + max_le_score = -Inf + + for (i, v) in enumerate(vertices) + gamma_v = atan(-v[2], vz_centered[i]) + + # Check if vertex is in the adjacent slice region + in_range = if gamma_search ≤ 0 + gamma_search - stepsize ≤ gamma_v ≤ gamma_search + else + gamma_search ≤ gamma_v ≤ gamma_search + stepsize end - elseif gamma > 0 && gamma ≤ gamma_v ≤ gamma + stepsize - if v[1] > trailing_edges[1, j] - trailing_edges[:, j] .= v - te_gammas[j] = gamma_v + + if in_range + if is_first_tip + # TE: furthest in [X, Y, -Z] direction + te_score = v[1] + v[2] - v[3] + if te_score > max_te_score + trailing_edges[:, j] .= v + te_gammas[j] = gamma_v + max_te_score = te_score + end + # LE: furthest in [-X, Y, -Z] direction + le_score = -v[1] + v[2] - v[3] + if le_score > max_le_score + leading_edges[:, j] .= v + le_gammas[j] = gamma_v + max_le_score = le_score + end + else # is_last_tip + # TE: furthest in [X, -Y, -Z] direction + te_score = v[1] - v[2] - v[3] + if te_score > max_te_score + trailing_edges[:, j] .= v + te_gammas[j] = gamma_v + max_te_score = te_score + end + # LE: furthest in [-X, -Y, -Z] direction + le_score = -v[1] - v[2] - v[3] + if le_score > max_le_score + leading_edges[:, j] .= v + le_gammas[j] = gamma_v + max_le_score = le_score + end + end end - if v[1] < leading_edges[1, j] - leading_edges[:, j] .= v - le_gammas[j] = gamma_v + end + else + # Interior slices: use standard min/max x-coordinate search + for (i, v) in enumerate(vertices) + gamma_v = atan(-v[2], vz_centered[i]) + if gamma ≤ 0 && gamma - stepsize ≤ gamma_v ≤ gamma + if v[1] > trailing_edges[1, j] + trailing_edges[:, j] .= v + te_gammas[j] = gamma_v + end + if v[1] < leading_edges[1, j] + leading_edges[:, j] .= v + le_gammas[j] = gamma_v + end + elseif gamma > 0 && gamma ≤ gamma_v ≤ gamma + stepsize + if v[1] > trailing_edges[1, j] + trailing_edges[:, j] .= v + te_gammas[j] = gamma_v + end + if v[1] < leading_edges[1, j] + leading_edges[:, j] .= v + le_gammas[j] = gamma_v + end end end end + area = norm(leading_edges[:, j] - trailing_edges[:, j]) * stepsize * radius last_area = j > 1 ? areas[j-1] : 0.0 areas[j] = last_area + area @@ -159,15 +218,103 @@ function create_interpolations(vertices, circle_center_z, radius, gamma_tip, R=I trailing_edges[:, j] .= R * trailing_edges[:, j] end - le_interp = ntuple(i -> linear_interpolation(te_gammas, leading_edges[i, :], + le_interp = ntuple(i -> linear_interpolation(le_gammas, leading_edges[i, :], extrapolation_bc=Line()), 3) - te_interp = ntuple(i -> linear_interpolation(le_gammas, trailing_edges[i, :], + te_interp = ntuple(i -> linear_interpolation(te_gammas, trailing_edges[i, :], extrapolation_bc=Line()), 3) area_interp = linear_interpolation(gamma_range, areas, extrapolation_bc=Line()) - + return (le_interp, te_interp, area_interp) end +""" + refine_obj_wing!(wing::AbstractWing; recompute_mapping=true) + +Refine OBJ wing by computing position deltas and applying them to refined sections. + +This method enables deformation support for OBJ wings by: +1. Recalculating evenly-spaced gammas for unrefined sections +2. Computing what unrefined sections SHOULD be (from interpolations) +3. Computing deltas between current and interpolated positions +4. Creating refined sections from interpolations + interpolated deltas +5. Computing panel mapping + +# Arguments +- `wing::AbstractWing`: OBJ wing with le_interp/te_interp +- `recompute_mapping::Bool=true`: Whether to recompute refined_panel_mapping + +# Effects +Updates wing.refined_sections and wing.non_deformed_sections in-place. +""" +function refine_obj_wing!(wing::AbstractWing; recompute_mapping=true) + n_unrefined = wing.n_unrefined_sections + n_refined = wing.n_panels + 1 + + # 1. Calculate evenly spaced gammas for unrefined sections + unrefined_gammas = range(-wing.gamma_tip, wing.gamma_tip, n_unrefined) + + # 2. Recalculate what unrefined sections SHOULD be from interpolations + interpolated_unrefined_le = Matrix{Float64}(undef, n_unrefined, 3) + interpolated_unrefined_te = Matrix{Float64}(undef, n_unrefined, 3) + for (i, gamma) in enumerate(unrefined_gammas) + interpolated_unrefined_le[i, :] .= [wing.le_interp[j](gamma) for j in 1:3] + interpolated_unrefined_te[i, :] .= [wing.te_interp[j](gamma) for j in 1:3] + end + + # 3. Compute deltas: current - interpolated + deltas_le = Matrix{Float64}(undef, n_unrefined, 3) + deltas_te = Matrix{Float64}(undef, n_unrefined, 3) + for i in 1:n_unrefined + deltas_le[i, :] .= wing.unrefined_sections[i].LE_point - + interpolated_unrefined_le[i, :] + deltas_te[i, :] .= wing.unrefined_sections[i].TE_point - + interpolated_unrefined_te[i, :] + end + + # 4. Create refined sections with interpolated deltas + refined_gammas = range(-wing.gamma_tip, wing.gamma_tip, n_refined) + if isempty(wing.refined_sections) + wing.refined_sections = [Section() for _ in 1:n_refined] + end + + for (idx, gamma) in enumerate(refined_gammas) + # Get base position from interpolation + base_le = [wing.le_interp[i](gamma) for i in 1:3] + base_te = [wing.te_interp[i](gamma) for i in 1:3] + + # Find surrounding unrefined sections for delta interpolation + unrefined_idx = searchsortedlast(collect(unrefined_gammas), gamma) + unrefined_idx = clamp(unrefined_idx, 1, n_unrefined - 1) + + # Linear interpolation weight + gamma_left = unrefined_gammas[unrefined_idx] + gamma_right = unrefined_gammas[unrefined_idx + 1] + t = (gamma - gamma_left) / (gamma_right - gamma_left) + + # Interpolate deltas + delta_le = (1 - t) * deltas_le[unrefined_idx, :] + + t * deltas_le[unrefined_idx + 1, :] + delta_te = (1 - t) * deltas_te[unrefined_idx, :] + + t * deltas_te[unrefined_idx + 1, :] + + # Apply deltas to get final position + final_le = base_le + delta_le + final_te = base_te + delta_te + + # Update refined section + aero_model = wing.unrefined_sections[1].aero_model + aero_data = wing.unrefined_sections[1].aero_data + VortexStepMethod.reinit!(wing.refined_sections[idx], final_le, final_te, + aero_model, aero_data) + end + + # 5. Compute panel mapping and update non_deformed_sections + recompute_mapping && VortexStepMethod.compute_refined_panel_mapping!(wing) + VortexStepMethod.update_non_deformed_sections!(wing) + + return nothing +end + """ center_to_com!(vertices, faces) @@ -187,26 +334,26 @@ Calculate center of mass of a mesh and translate vertices so that COM is at orig function center_to_com!(vertices, faces; prn=true) area_total = 0.0 com = zeros(3) - + for face in faces if length(face) == 3 # Triangle case v1 = vertices[face[1]] v2 = vertices[face[2]] v3 = vertices[face[3]] - + # Calculate triangle area and centroid normal = cross(v2 - v1, v3 - v1) area = norm(normal) / 2 centroid = (v1 + v2 + v3) / 3 - + area_total += area com -= area * centroid else throw(ArgumentError("Triangulate faces in a CAD program first")) end end - + com = com / area_total !(abs(com[2]) < 0.01) && throw(ArgumentError("Center of mass $com of .obj file has to lie on the xz-plane.")) prn && @info "Centering vertices of .obj file to the center of mass: $com" @@ -220,7 +367,7 @@ end """ calculate_inertia_tensor(vertices, faces, mass, com) -Calculate the inertia tensor for a triangulated surface mesh, assuming a thin shell with uniform +Calculate the inertia tensor for a triangulated surface mesh, assuming a thin shell with uniform surface density. # Arguments @@ -244,23 +391,23 @@ function calculate_inertia_tensor(vertices, faces, mass, com) # Initialize inertia tensor I = zeros(3, 3) total_area = 0.0 - + for face in faces v1 = vertices[face[1]] .- com v2 = vertices[face[2]] .- com v3 = vertices[face[3]] .- com - + # Calculate triangle area normal = cross(v2 - v1, v3 - v1) area = norm(normal) / 2 total_area += area - + # Calculate contribution to inertia tensor for i in 1:3 for j in 1:3 # Vertices relative to center of mass points = [v1, v2, v3] - + # Calculate contribution to inertia tensor for p in points if i == j @@ -274,7 +421,7 @@ function calculate_inertia_tensor(vertices, faces, mass, com) end end end - + # Scale by mass/total_area to get actual inertia tensor return (mass / total_area) * I / 3 end @@ -291,14 +438,14 @@ function calc_inertia_y_rotation(I_b_tensor) # Transform inertia tensor I_rotated = R_y * I_b_tensor * R_y' # We want the off-diagonal xz elements to be zero - F[1] = I_rotated[1,3] + F[1] = I_rotated[1,3] end - + theta0 = [0.0] prob = NonlinearProblem(eq!, theta0, nothing) sol = NonlinearSolve.solve(prob, NewtonRaphson()) theta_opt = sol.u[1] - + R_b_p = [ cos(theta_opt) 0 sin(theta_opt); 0 1 0; @@ -312,67 +459,18 @@ end """ - RamAirWing <: AbstractWing - -A ram-air wing model that represents a curved parafoil with deformable aerodynamic surfaces. - -## Core Features -- Curved wing geometry derived from 3D mesh (.obj file) -- Aerodynamic properties based on 2D airfoil data (.dat file) -- Support for control inputs (twist angles and trailing edge deflections) -- Inertial and geometric properties calculation - -## Notable Fields -- `n_panels::Int16`: Number of panels in aerodynamic mesh -- `n_groups::Int16`: Number of control groups for distributed deformation -- `mass::Float64`: Total wing mass in kg -- `gamma_tip::Float64`: Angular extent from center to wing tip -- `inertia_tensor::Matrix{Float64}`: Full 3x3 inertia tensor in the kite body frame -- `T_cad_body::MVec3`: Translation vector from CAD frame to body frame -- `R_cad_body::MMat3`: Rotation matrix from CAD frame to body frame -- `radius::Float64`: Wing curvature radius -- `theta_dist::Vector{Float64}`: Panel twist angle distribution -- `delta_dist::Vector{Float64}`: Trailing edge deflection distribution - -See constructor `RamAirWing(obj_path, dat_path; kwargs...)` for usage details. -""" -mutable struct RamAirWing <: AbstractWing - n_panels::Int16 - n_groups::Int16 - spanwise_distribution::PanelDistribution - panel_props::PanelProperties - spanwise_direction::MVec3 - sections::Vector{Section} - refined_sections::Vector{Section} - remove_nan::Bool - - # Additional fields for RamAirWing - non_deformed_sections::Vector{Section} - mass::Float64 - gamma_tip::Float64 - inertia_tensor::Matrix{Float64} - T_cad_body::MVec3 - R_cad_body::MMat3 - radius::Float64 - le_interp::NTuple{3, Extrapolation} - te_interp::NTuple{3, Extrapolation} - area_interp::Extrapolation - theta_dist::Vector{Float64} - delta_dist::Vector{Float64} - cache::Vector{PreallocationTools.LazyBufferCache{typeof(identity), typeof(identity)}} -end - -""" - RamAirWing(obj_path, dat_path; kwargs...) + ObjWing(obj_path, dat_path; kwargs...) -Create a ram-air wing model from 3D geometry and airfoil data files. +Create a deformable wing model from 3D geometry (.obj) and airfoil data (.dat) files. This constructor builds a complete aerodynamic model by: -1. Loading or generating wing geometry from the .obj file -2. Creating aerodynamic polars from the airfoil .dat file +1. Loading wing geometry from the .obj file +2. Creating aerodynamic polars from the airfoil .dat file (or loading existing) 3. Computing inertial properties and coordinate transformations 4. Setting up control surfaces and panel distribution +The resulting Wing supports deformation through unrefined_deform! and deform! functions. + # Arguments - `obj_path`: Path to .obj file containing 3D wing geometry - `dat_path`: Path to .dat file containing 2D airfoil profile @@ -382,40 +480,48 @@ This constructor builds a complete aerodynamic model by: - `wind_vel=10.0`: Reference wind velocity for XFoil analysis (m/s) - `mass=1.0`: Wing mass (kg) - `n_panels=56`: Number of aerodynamic panels across wingspan -- `n_groups=4`: Number of control groups for deformation -- `n_sections=n_panels+1`: Number of spanwise cross-sections +- `n_unrefined_sections`: Number of unrefined sections for deformation control (default: inferred from geometry) - `align_to_principal=false`: Align body frame to principal axes of inertia -- `spanwise_distribution=UNCHANGED`: Panel distribution type +- `spanwise_distribution=UNCHANGED`: Panel distribution type (forced to UNCHANGED for ObjWing) - `remove_nan=true`: Interpolate NaN values in aerodynamic data - `alpha_range=deg2rad.(-5:1:20)`: Angle of attack range for polars (rad) - `delta_range=deg2rad.(-5:1:20)`: Trailing edge deflection range for polars (rad) -- prn=true: if info messages shall be printed +- `prn=true`: Print informational messages # Returns -A fully initialized `RamAirWing` instance ready for aerodynamic simulation. +A fully initialized `Wing` instance ready for aerodynamic simulation with deformation support. # Example ```julia -# Create a ram-air wing from geometry files -wing = RamAirWing( +# Create a deformable wing from geometry files +wing = ObjWing( "path/to/wing.obj", "path/to/airfoil.dat"; mass=1.5, n_panels=40, - n_groups=4 + n_unrefined_sections=4 ) + +# Apply deformation +unrefined_deform!(wing, deg2rad.([5, 10, 5, 0]), deg2rad.([-5, 0, -5, 0])) ``` """ -function RamAirWing( - obj_path, dat_path; - crease_frac=0.9, wind_vel=10., mass=1.0, - n_panels=56, n_sections=n_panels+1, n_groups=4, spanwise_distribution=UNCHANGED, +function ObjWing( + obj_path, dat_path; + crease_frac=0.9, wind_vel=10., mass=1.0, + n_panels=56, n_unrefined_sections=nothing, + spanwise_distribution=UNCHANGED, spanwise_direction=[0.0, 1.0, 0.0], remove_nan=true, align_to_principal=false, alpha_range=deg2rad.(-5:1:20), delta_range=deg2rad.(-5:1:20), prn=true, - interp_steps=n_sections # TODO: check if interpolations are still needed + interp_steps=n_panels+1 ) - !(n_panels % n_groups == 0) && throw(ArgumentError("Number of panels should be divisible by number of groups")) + # Set default: evenly spaced unrefined sections including both tips + if isnothing(n_unrefined_sections) + # Default to having same number of unrefined sections as refined (no interpolation needed) + n_unrefined_sections = n_panels + 1 + end + !isapprox(spanwise_direction, [0.0, 1.0, 0.0]) && throw(ArgumentError("Spanwise direction has to be [0.0, 1.0, 0.0], not $spanwise_direction")) # Load or create polars @@ -436,7 +542,7 @@ function RamAirWing( if align_to_principal inertia_tensor, R_cad_body = calc_inertia_y_rotation(inertia_tensor) else - R_cad_body = I(3) + R_cad_body = Matrix{Float64}(I, 3, 3) end circle_center_z, radius, gamma_tip = find_circle_center_and_radius(vertices) le_interp, te_interp, area_interp = create_interpolations(vertices, circle_center_z, radius, gamma_tip, R_cad_body; interp_steps) @@ -446,7 +552,7 @@ function RamAirWing( if !ispath(cl_polar_path) || !ispath(cd_polar_path) || !ispath(cm_polar_path) width = 2gamma_tip * radius area = area_interp(gamma_tip) - create_polars(; dat_path, cl_polar_path, cd_polar_path, cm_polar_path, wind_vel, + create_polars(; dat_path, cl_polar_path, cd_polar_path, cm_polar_path, wind_vel, area, width, crease_frac, alpha_range, delta_range, remove_nan) end @@ -459,27 +565,31 @@ function RamAirWing( any(isnan.(cd_matrix)) && interpolate_matrix_nans!(cd_matrix; prn) any(isnan.(cm_matrix)) && interpolate_matrix_nans!(cm_matrix; prn) end - - # Create sections + + # Create unrefined sections (evenly spaced including both tips) sections = Section[] - refined_sections = Section[] - non_deformed_sections = Section[] - for gamma in range(-gamma_tip, gamma_tip, n_sections) - aero_data = (collect(alpha_range), collect(delta_range), cl_matrix, cd_matrix, cm_matrix) + aero_data = (collect(alpha_range), collect(delta_range), cl_matrix, cd_matrix, cm_matrix) + for gamma in range(-gamma_tip, gamma_tip, n_unrefined_sections) LE_point = [le_interp[i](gamma) for i in 1:3] TE_point = [te_interp[i](gamma) for i in 1:3] push!(sections, Section(LE_point, TE_point, POLAR_MATRICES, aero_data)) - push!(refined_sections, Section(LE_point, TE_point, POLAR_MATRICES, aero_data)) - push!(non_deformed_sections, Section(LE_point, TE_point, POLAR_MATRICES, aero_data)) end panel_props = PanelProperties{n_panels}() - cache = [LazyBufferCache()] + cache = [PreallocationTools.LazyBufferCache()] - RamAirWing(n_panels, n_groups, spanwise_distribution, panel_props, spanwise_direction, sections, - refined_sections, remove_nan, non_deformed_sections, + wing = Wing(n_panels, Int16(n_unrefined_sections), spanwise_distribution, panel_props, MVec3(spanwise_direction), + sections, Section[], remove_nan, # refined_sections empty + Int16[], # refined_panel_mapping empty + Section[], zeros(n_panels), zeros(n_panels), # non_deformed, theta, delta mass, gamma_tip, inertia_tensor, T_cad_body, R_cad_body, radius, - le_interp, te_interp, area_interp, zeros(n_panels), zeros(n_panels), cache) + le_interp, te_interp, area_interp, cache) + + # Auto-refine for backward compatibility + refine_obj_wing!(wing; recompute_mapping=true) + reinit!(wing) + + wing catch e if e isa BoundsError @@ -488,110 +598,3 @@ function RamAirWing( rethrow(e) end end - -""" - group_deform!(wing::RamAirWing, theta_angles::AbstractVector, delta_angles::AbstractVector) - -Distribute control angles across wing panels and apply smoothing using a moving average filter. - -# Arguments -- `wing::RamAirWing`: The wing to deform -- `theta_angles::AbstractVector`: Twist angles in radians for each control section -- `delta_angles::AbstractVector`: Trailing edge deflection angles in radians for each control section -- `smooth::Bool`: Wether to apply smoothing or not - -# Algorithm -1. Distributes each control input to its corresponding group of panels -2. Applies moving average smoothing with window size based on control group size - -# Errors -- Throws `ArgumentError` if panel count is not divisible by the number of control inputs - -# Returns -- `nothing` (modifies wing in-place) -""" -function group_deform!(wing::RamAirWing, theta_angles=nothing, delta_angles=nothing; smooth=false) - !isnothing(theta_angles) && !(wing.n_panels % length(theta_angles) == 0) && - throw(ArgumentError("Number of angles has to be a multiple of number of panels")) - !isnothing(delta_angles) && !(wing.n_panels % length(delta_angles) == 0) && - throw(ArgumentError("Number of angles has to be a multiple of number of panels")) - isnothing(theta_angles) && isnothing(delta_angles) && return nothing - - n_panels = wing.n_panels - theta_dist = wing.theta_dist - delta_dist = wing.delta_dist - n_angles = isnothing(theta_angles) ? length(delta_angles) : length(theta_angles) - - dist_idx = 0 - for angle_idx in 1:n_angles - for _ in 1:(wing.n_panels ÷ n_angles) - dist_idx += 1 - !isnothing(theta_angles) && (theta_dist[dist_idx] = theta_angles[angle_idx]) - !isnothing(delta_angles) && (delta_dist[dist_idx] = delta_angles[angle_idx]) - end - end - @assert (dist_idx == wing.n_panels) - - if smooth - window_size = wing.n_panels ÷ n_angles - if n_panels > window_size - smoothed = wing.cache[1][theta_dist] - - if !isnothing(theta_angles) - smoothed .= theta_dist - for i in (window_size÷2 + 1):(n_panels - window_size÷2) - @views smoothed[i] = mean(theta_dist[(i - window_size÷2):(i + window_size÷2)]) - end - theta_dist .= smoothed - end - - if !isnothing(delta_angles) - smoothed .= delta_dist - for i in (window_size÷2 + 1):(n_panels - window_size÷2) - @views smoothed[i] = mean(delta_dist[(i - window_size÷2):(i + window_size÷2)]) - end - delta_dist .= smoothed - end - end - end - deform!(wing) - return nothing -end - -""" - deform!(wing::RamAirWing, theta_dist::AbstractVector, delta_dist::AbstractVector; width) - -Deform wing by applying theta and delta distributions. - -# Arguments -- `wing`: RamAirWing to deform -- `theta_dist`: the angle distribution between of the kite and the body x-axis in radians of each panel -- `delta_dist`: the deformation of the trailing edges of each panel - -# Effects -Updates wing.sections with deformed geometry -""" -function deform!(wing::RamAirWing, theta_dist::AbstractVector, delta_dist::AbstractVector) - !(length(theta_dist) == wing.n_panels) && throw(ArgumentError("theta_dist and panels are of different lengths")) - !(length(delta_dist) == wing.n_panels) && throw(ArgumentError("delta_dist and panels are of different lengths")) - wing.theta_dist .= theta_dist - wing.delta_dist .= delta_dist - - deform!(wing) -end - -function deform!(wing::RamAirWing) - local_y = zeros(MVec3) - chord = zeros(MVec3) - normal = zeros(MVec3) - - for i in 1:wing.n_panels - section1 = wing.non_deformed_sections[i] - section2 = wing.non_deformed_sections[i+1] - local_y .= normalize(section1.LE_point - section2.LE_point) - chord .= section1.TE_point .- section1.LE_point - normal .= chord × local_y - @. wing.sections[i].TE_point = section1.LE_point + cos(wing.theta_dist[i]) * chord - sin(wing.theta_dist[i]) * normal - end - return nothing -end diff --git a/src/panel.jl b/src/panel.jl index 23886f4d..ba2e2448 100644 --- a/src/panel.jl +++ b/src/panel.jl @@ -535,4 +535,4 @@ function calculate_velocity_induced_bound_2D!( cross_square .= cross_.^2 U_2D .= (cross_ ./ sum(cross_square) ./ 2π) .* norm(r0) return nothing -end \ No newline at end of file +end diff --git a/src/plotting.jl b/src/plotting.jl deleted file mode 100644 index 3c602edb..00000000 --- a/src/plotting.jl +++ /dev/null @@ -1,920 +0,0 @@ - -""" - set_plot_style(titel_size=16; use_tex=false) - -Set the default style for plots using LaTeX. -`` -# Arguments: -- `titel_size`: size of the plot title in points (default: 16) -- `ùse_tex`: if the external `pdflatex` command shall be used -""" -function set_plot_style(titel_size=16; use_tex=false) - rcParams = plt.PyDict(plt.matplotlib."rcParams") - rcParams["text.usetex"] = use_tex - rcParams["font.family"] = "serif" - if use_tex - rcParams["font.serif"] = ["Computer Modern Roman"] - end - rcParams["axes.titlesize"] = titel_size - rcParams["axes.labelsize"] = 12 - rcParams["axes.linewidth"] = 1 - rcParams["lines.linewidth"] = 1 - rcParams["lines.markersize"] = 6 - rcParams["xtick.labelsize"] = 10 - rcParams["ytick.labelsize"] = 10 - rcParams["legend.fontsize"] = 10 - rcParams["figure.titlesize"] = 16 - if use_tex - rcParams["pgf.texsystem"] = "pdflatex" # Use pdflatex - end - rcParams["pgf.rcfonts"] = false - rcParams["figure.figsize"] = (10, 6) # Default figure size -end - - -""" - save_plot(fig, save_path, title; data_type=".pdf") - -Save a plot to a file. - -# Arguments -- `fig`: Plots figure object -- `save_path`: Path to save the plot -- `title`: Title of the plot - -# Keyword arguments -- `data_type`: File extension (default: ".pdf") -""" -function VortexStepMethod.save_plot(fig, save_path, title; data_type=".pdf") - isnothing(save_path) && throw(ArgumentError("save_path should be provided")) - - !isdir(save_path) && mkpath(save_path) - full_path = joinpath(save_path, title * data_type) - - @debug "Attempting to save figure to: $full_path" - @debug "Current working directory: $(pwd())" - - try - fig.savefig(full_path) - @debug "Figure saved as $data_type" - - if isfile(full_path) - @debug "File successfully saved to $full_path" - @debug "File size: $(filesize(full_path)) bytes" - else - @info "File does not exist after save attempt: $full_path" - end - catch e - @error "Error saving figure: $e" - @error "Error type: $(typeof(e))" - rethrow(e) - end -end - -""" - show_plot(fig; dpi=130) - -Display a plot at specified DPI. - -# Arguments -- `fig`: Plots figure object - -# Keyword arguments -- `dpi`: Dots per inch for the figure (default: 130) -""" -function VortexStepMethod.show_plot(fig; dpi=130) - plt.display(fig) -end - -""" - plot_line_segment!(ax, segment, color, label; width=3) - -Plot a line segment in 3D with arrow. - -# Arguments -- `ax`: Plot axis -- `segment`: Array of two points defining the segment -- `color`: Color of the segment -- `label`: Label for the legend - -# Keyword Arguments -- `width`: Line width (default: 3) -""" -function plot_line_segment!(ax, segment, color, label; width=3) - ax.plot( - [segment[1][1], segment[2][1]], - [segment[1][2], segment[2][2]], - [segment[1][3], segment[2][3]], - color=color, label=label, linewidth=width - ) - - dir = segment[2] - segment[1] - ax.quiver( - [segment[1][1]], [segment[1][2]], [segment[1][3]], - [dir[1]], [dir[2]], [dir[3]], - color=color - ) -end - -""" - set_axes_equal!(ax; zoom=1.8) - -Set 3D plot axes to equal scale. - -# Arguments -- ax: 3D plot axis - -# Keyword arguments -zoom: zoom factor (default: 1.8) -""" -function set_axes_equal!(ax; zoom=1.8) - x_lims = ax.get_xlim3d() ./ zoom - y_lims = ax.get_ylim3d() ./ zoom - z_lims = ax.get_zlim3d() ./ zoom - - x_range = abs(x_lims[2] - x_lims[1]) - y_range = abs(y_lims[2] - y_lims[1]) - z_range = abs(z_lims[2] - z_lims[1]) - - max_range = max(x_range, y_range, z_range) - - x_mid = mean(x_lims) - y_mid = mean(y_lims) - z_mid = mean(z_lims) - - ax.set_xlim3d((x_mid - max_range / 2, x_mid + max_range / 2)) - ax.set_ylim3d((y_mid - max_range / 2, y_mid + max_range / 2)) - ax.set_zlim3d((z_mid - max_range / 2, z_mid + max_range / 2)) -end - -""" - create_geometry_plot(body_aero::BodyAerodynamics, title, view_elevation, view_azimuth; - zoom=1.8, use_tex=false) - -Create a 3D plot of wing geometry including panels and filaments. - -# Arguments -- body_aero: struct of type BodyAerodynamics -- title: plot title -- view_elevation: initial view elevation angle [°] -- view_azimuth: initial view azimuth angle [°] - -# Keyword arguments -- zoom: zoom factor (default: 1.8) -""" -function create_geometry_plot(body_aero::BodyAerodynamics, title, view_elevation, view_azimuth; - zoom=1.8, use_tex=false) - set_plot_style(28; use_tex) - - panels = body_aero.panels - va = isa(body_aero.va, Tuple) ? body_aero.va[1] : body_aero.va - - # Extract geometric data - corner_points = [panel.corner_points for panel in panels] - control_points = [panel.control_point for panel in panels] - aero_centers = [panel.aero_center for panel in panels] - - # Create plot - fig = plt.figure(figsize=(14, 14)) - ax = fig.add_subplot(111, projection="3d") - ax.set_title(title) - - # Plot panels - legend_used = Dict{String,Bool}() - for (i, panel) in enumerate(panels) - # Plot panel edges and surfaces - corners = Matrix{Float64}(panel.corner_points) - x_corners = corners[1, :] - y_corners = corners[2, :] - z_corners = corners[3, :] - - push!(x_corners, x_corners[1]) - push!(y_corners, y_corners[1]) - push!(z_corners, z_corners[1]) - - ax.plot(x_corners, - y_corners, - z_corners, - color=:grey, - linewidth=1, - label=i == 1 ? "Panel Edges" : "") - - # Plot control points and aerodynamic centers - ax.scatter([control_points[i][1]], [control_points[i][2]], [control_points[i][3]], - color=:green, label=i == 1 ? "Control Points" : "") - ax.scatter([aero_centers[i][1]], [aero_centers[i][2]], [aero_centers[i][3]], - color=:blue, label=i == 1 ? "Aerodynamic Centers" : "") - - # Plot filaments - filaments = calculate_filaments_for_plotting(panel) - legends = ["Bound Vortex", "side1", "side2", "wake_1", "wake_2"] - - for (filament, legend) in zip(filaments, legends) - x1, x2, color = filament - @debug "Legend: $legend" - show_legend = !get(legend_used, legend, false) - plot_line_segment!(ax, [x1, x2], color, show_legend ? legend : "") - legend_used[legend] = true - end - end - - # Plot velocity vector - max_chord = maximum(panel.chord for panel in panels) - va_mag = norm(va) - va_vector_begin = -2 * max_chord * va / va_mag - va_vector_end = va_vector_begin + 1.5 * va / va_mag - plot_line_segment!(ax, [va_vector_begin, va_vector_end], :lightblue, "va") - - # Add legends for the first occurrence of each label - handles, labels = ax.get_legend_handles_labels() - # by_label = Dict(zip(labels, handles)) - # ax.legend(values(by_label), keys(by_label), bbox_to_anchor=(0, 0, 1.1, 1)) - - # Set labels and make axes equal - ax.set_xlabel("x") - ax.set_ylabel("y") - ax.set_zlabel("z") - set_axes_equal!(ax; zoom) - - # Set the initial view - ax.view_init(elev=view_elevation, azim=view_azimuth) - - # Ensure the figure is fully rendered - # fig.canvas.draw() - plt.tight_layout(rect=(0, 0, 1, 0.97)) - - return fig -end - -""" - plot_geometry(body_aero::BodyAerodynamics, title; - data_type=".pdf", save_path=nothing, - is_save=false, is_show=false, - view_elevation=15, view_azimuth=-120, use_tex=false) - -Plot wing geometry from different viewpoints and optionally save/show plots. - -# Arguments: -- `body_aero`: the [BodyAerodynamics](@ref) to plot -- title: plot title - -# Keyword arguments: -- `data_type``: string with the file type postfix (default: ".pdf") -- `save_path`: path for saving the graphic (default: `nothing`) -- `is_save`: boolean value, indicates if the graphic shall be saved (default: `false`) -- `is_show`: boolean value, indicates if the graphic shall be displayed (default: `false`) -- `view_elevation`: initial view elevation angle (default: 15) [°] -- `view_azimuth`: initial view azimuth angle (default: -120) [°] -- `use_tex`: if the external `pdflatex` command shall be used (default: false) - -""" -function VortexStepMethod.plot_geometry(body_aero::BodyAerodynamics, title; - data_type=".pdf", - save_path=nothing, - is_save=false, - is_show=false, - view_elevation=15, - view_azimuth=-120, - use_tex=false) - - if is_save - plt.ioff() - # Angled view - fig = create_geometry_plot(body_aero, "$(title)_angled_view", 15, -120; use_tex) - save_plot(fig, save_path, "$(title)_angled_view", data_type=data_type) - - # Top view - fig = create_geometry_plot(body_aero, "$(title)_top_view", 90, 0; use_tex) - save_plot(fig, save_path, "$(title)_top_view", data_type=data_type) - - # Front view - fig = create_geometry_plot(body_aero, "$(title)_front_view", 0, 0; use_tex) - save_plot(fig, save_path, "$(title)_front_view", data_type=data_type) - - # Side view - fig = create_geometry_plot(body_aero, "$(title)_side_view", 0, -90; use_tex) - save_plot(fig, save_path, "$(title)_side_view", data_type=data_type) - end - - if is_show - plt.ion() - fig = create_geometry_plot(body_aero, title, view_elevation, view_azimuth; use_tex) - plt.display(fig) - else - fig = create_geometry_plot(body_aero, title, view_elevation, view_azimuth; use_tex) - end - fig -end - -""" - plot_distribution(y_coordinates_list, results_list, label_list; - title="spanwise_distribution", data_type=".pdf", - save_path=nothing, is_save=false, is_show=true, use_tex=false) - -Plot spanwise distributions of aerodynamic properties. - -# Arguments -- `y_coordinates_list`: List of spanwise coordinates -- `results_list`: List of result dictionaries -- `label_list`: List of labels for different results - -# Keyword arguments -- `title`: Plot title (default: "spanwise_distribution") -- `data_type`: File extension for saving (default: ".pdf") -- `save_path`: Path to save plots (default: nothing) -- `is_save`: Whether to save plots (default: false) -- `is_show`: Whether to display plots (default: true) -- `use_tex`: if the external `pdflatex` command shall be used -""" -function VortexStepMethod.plot_distribution(y_coordinates_list, results_list, label_list; - title="spanwise_distribution", - data_type=".pdf", - save_path=nothing, - is_save=false, - is_show=true, - use_tex=false) - - length(results_list) == length(label_list) || throw(ArgumentError( - "Number of results ($(length(results_list))) must match number of labels ($(length(label_list)))" - )) - - # Set the plot style - set_plot_style(; use_tex) - - # Initializing plot - fig, axs = plt.subplots(3, 3, figsize=(16, 10)) - fig.suptitle(title, fontsize=16) - - # CL plot - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - value = "$(round(result_i["cl"], digits=2))" - if label_i == "LLT" - label = label_i * L" $~C_\mathrm{L}$: " * value - else - label = label_i * L" $C_\mathrm{L}$: " * value - end - axs[1, 1].plot( - y_coordinates_i, - result_i["cl_distribution"], - label=label - ) - end - axs[1, 1].set_title(L"$C_\mathrm{L}$ Distribution", size=16) - axs[1, 1].set_xlabel(L"Spanwise Position $y/b$") - axs[1, 1].set_ylabel(L"Lift Coefficient $C_\mathrm{L}$") - axs[1, 1].legend() - - # CD plot - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - value = "$(round(result_i["cl"], digits=2))" - if label_i == "LLT" - label = label_i * L" $~C_\mathrm{D}$: " * value - else - label = label_i * L" $C_\mathrm{D}$: " * value - end - axs[1, 2].plot( - y_coordinates_i, - result_i["cd_distribution"], - label=label - ) - end - axs[1, 2].set_title(L"$C_\mathrm{D}$ Distribution", size=16) - axs[1, 2].set_xlabel(L"Spanwise Position $y/b$") - axs[1, 2].set_ylabel(L"Drag Coefficient $C_\mathrm{D}$") - axs[1, 2].legend() - - # Gamma Distribution - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - axs[1, 3].plot( - y_coordinates_i, - result_i["gamma_distribution"], - label=label_i - ) - end - axs[1, 3].set_title(L"\Gamma~Distribution", size=16) - axs[1, 3].set_xlabel(L"Spanwise Position $y/b$") - axs[1, 3].set_ylabel(L"Circulation~\Gamma") - axs[1, 3].legend() - - # Geometric Alpha - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - axs[2, 1].plot( - y_coordinates_i, - result_i["alpha_geometric"], - label=label_i - ) - end - axs[2, 1].set_title(L"$\alpha$ Geometric", size=16) - axs[2, 1].set_xlabel(L"Spanwise Position $y/b$") - axs[2, 1].set_ylabel(L"Angle of Attack $\alpha$ (deg)") - axs[2, 1].legend() - - # Calculated/ Corrected Alpha - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - axs[2, 2].plot( - y_coordinates_i, - result_i["alpha_at_ac"], - label=label_i - ) - end - axs[2, 2].set_title(L"$\alpha$ result (corrected to aerodynamic center)", size=16) - axs[2, 2].set_xlabel(L"Spanwise Position $y/b$") - axs[2, 2].set_ylabel(L"Angle of Attack $\alpha$ (deg)") - axs[2, 2].legend() - - # Uncorrected Alpha plot - for (y_coordinates_i, result_i, label_i) in zip(y_coordinates_list, results_list, label_list) - axs[2, 3].plot( - y_coordinates_i, - result_i["alpha_uncorrected"], - label=label_i - ) - end - axs[2, 3].set_title(L"$\alpha$ Uncorrected (if VSM, at the control point)", size=16) - axs[2, 3].set_xlabel(L"Spanwise Position $y/b$") - axs[2, 3].set_ylabel(L"Angle of Attack $\alpha$ (deg)") - axs[2, 3].legend() - - # Force Components - for (idx, component) in enumerate(["x", "y", "z"]) - axs[3, idx].set_title("Force in $component direction", size=16) - axs[3, idx].set_xlabel(L"Spanwise Position $y/b$") - axs[3, idx].set_ylabel(raw"$F_\mathrm" * "{$component}" * raw"$") - for (y_coords, results, label) in zip(y_coordinates_list, results_list, label_list) - # Extract force components for the current direction (idx) - forces = results["F_distribution"][idx, :] - # Verify dimensions match - if length(y_coords) != length(forces) - @warn "Dimension mismatch in force plotting" length(y_coords) length(forces) component - continue # Skip this component instead of throwing error - end - space = "" - if label == "LLT" - space = "~" - end - axs[3, idx].plot( - y_coords, - forces, - label="$label" * space * raw"$~\Sigma~F_\mathrm" * "{$component}:~" * - raw"$" * "$(round(results["F$component"], digits=2)) N" - ) - axs[3, idx].legend() - end - end - - fig.tight_layout() - - # Save and show plot - if is_save - save_plot(fig, save_path, title, data_type=data_type) - end - - if is_show - show_plot(fig) - end - - return fig -end - -""" - generate_polar_data(solver, body_aero::BodyAerodynamics, angle_range; - angle_type="angle_of_attack", angle_of_attack=0.0, - side_slip=0.0, v_a=10.0, use_latex=false) - -Generate polar data for aerodynamic analysis over a range of angles. - -# Arguments -- `solver`: Aerodynamic solver object -- `body_aero`: Wing aerodynamics struct -- `angle_range`: Range of angles to analyze - -# Keyword arguments -- `angle_type`: Type of angle variation ("angle_of_attack" or "side_slip") -- `angle_of_attack`: Initial angle of attack [rad] -- `side_slip`: Initial side slip angle in [rad] -- `v_a`: norm of apparent wind speed [m/s] - -# Returns -- Tuple of polar data array and Reynolds number -""" -function generate_polar_data( - solver, - body_aero::BodyAerodynamics, - angle_range; - angle_type="angle_of_attack", - angle_of_attack=0.0, - side_slip=0.0, - v_a=10.0, - use_latex=false -) - n_panels = length(body_aero.panels) - n_angles = length(angle_range) - - # Initialize arrays - cl = zeros(n_angles) - cd = zeros(n_angles) - cs = zeros(n_angles) - gamma_distribution = zeros(n_angles, n_panels) - cl_distribution = zeros(n_angles, n_panels) - cd_distribution = zeros(n_angles, n_panels) - cs_distribution = zeros(n_angles, n_panels) - reynolds_number = zeros(n_angles) - - # Previous gamma for initialization - gamma = nothing - - for (i, angle_i) in enumerate(angle_range) - # Set angle based on type - if angle_type == "angle_of_attack" - α = deg2rad(angle_i) - β = side_slip - elseif angle_type == raw"side_slip" - α = angle_of_attack - β = deg2rad(angle_i) - else - throw(ArgumentError("angle_type must be 'angle_of_attack' or 'side_slip'")) - end - - # Update inflow conditions - set_va!( - body_aero, - [ - cos(α) * cos(β), - sin(β), - sin(α) - ] * v_a - ) - - # Solve and store results - results = solve(solver, body_aero, gamma_distribution[i, :]) - - cl[i] = results["cl"] - cd[i] = results["cd"] - cs[i] = results["cs"] - gamma_distribution[i, :] = results["gamma_distribution"] - cl_distribution[i, :] = results["cl_distribution"] - cd_distribution[i, :] = results["cd_distribution"] - cs_distribution[i, :] = results["cs_distribution"] - reynolds_number[i] = results["Rey"] - - # Store gamma for next iteration - gamma = gamma_distribution[i, :] - end - - polar_data = [ - angle_range, - cl, - cd, - cs, - gamma_distribution, - cl_distribution, - cd_distribution, - cs_distribution, - reynolds_number - ] - - return polar_data, reynolds_number[1] -end - -""" - plot_polars(solver_list, body_aero_list, label_list; - literature_path_list=String[], - angle_range=range(0, 20, 2), angle_type="angle_of_attack", - angle_of_attack=0.0, side_slip=0.0, v_a=10.0, - title="polar", data_type=".pdf", save_path=nothing, - is_save=true, is_show=true, use_tex=false) - -Plot polar data comparing different solvers and configurations. - -# Arguments -- `solver_list`: List of aerodynamic solvers -- `body_aero_list`: List of wing aerodynamics objects -- `label_list`: List of labels for each configuration - -# Keyword arguments -- `literature_path_list`: Optional paths to literature data files -- `angle_range`: Range of angles to analyze [°] -- `angle_type`: "`angle_of_attack`" or "`side_slip`"; (default: `angle_of_attack`) -- `angle_of_attack:` AoA to be used for plotting the polars (default: 0.0) [rad] -- `side_slip`: side slip angle (default: 0.0) [rad] -- v_a: norm of apparent wind speed (default: 10.0) [m/s] -- title: plot title -- `data_type`: File extension for saving (default: ".pdf") -- `save_path`: Path to save plots (default: nothing) -- `is_save`: Whether to save plots (default: true) -- `is_show`: Whether to display plots (default: true) -- `use_tex`: if the external `pdflatex` command shall be used (default: false) -""" -function VortexStepMethod.plot_polars( - solver_list, - body_aero_list, - label_list; - literature_path_list=String[], - angle_range=range(0, 20, 2), - angle_type="angle_of_attack", - angle_of_attack=0.0, - side_slip=0.0, - v_a=10.0, - title="polar", - data_type=".pdf", - save_path=nothing, - is_save=true, - is_show=true, - use_tex=false -) - # Validate inputs - total_cases = length(body_aero_list) + length(literature_path_list) - if total_cases != length(label_list) || length(solver_list) != length(body_aero_list) - throw(ArgumentError("Mismatch in number of solvers ($(length(solver_list))), " * - "cases ($total_cases), and labels ($(length(label_list)))")) - end - main_title = replace(title, " " => "_") - set_plot_style(; use_tex) - - # Generate polar data - polar_data_list = [] - for (i, (solver, body_aero)) in enumerate(zip(solver_list, body_aero_list)) - polar_data, rey = generate_polar_data( - solver, body_aero, angle_range; - angle_type, - angle_of_attack, - side_slip, - v_a - ) - push!(polar_data_list, polar_data) - # Update label with Reynolds number - label_list[i] = "$(label_list[i]) Re = $(round(Int64, rey*1e-5))e5" - end - # Load literature data if provided - if !isempty(literature_path_list) - for path in literature_path_list - data = readdlm(path, ',') - header = lowercase.(string.(data[1, :])) - # Find column indices for alpha, CL, CD, CS (case-insensitive, allow common variants) - alpha_idx = findfirst(x -> occursin("alpha", x), header) - cl_idx = findfirst(x -> occursin("cl", x), header) - cd_idx = findfirst(x -> occursin("cd", x), header) - cs_idx = findfirst(x -> occursin("cs", x), header) - # Fallback: if CS not found, fill with zeros - cs_col = cs_idx === nothing ? zeros(size(data, 1)-1) : data[2:end, cs_idx] - # Push as [alpha, CL, CD, CS] - push!(polar_data_list, [ - data[2:end, alpha_idx], - data[2:end, cl_idx], - data[2:end, cd_idx], - cs_col - ]) - end - end - - # Initializing plot - fig, axs = plt.subplots(2, 2, figsize=(14, 14)) - - # Number of computational results (excluding literature) - n_solvers = length(solver_list) - for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) - if i < n_solvers - linestyle = "-" - marker = "*" - markersize = 7 - else - linestyle = "-" - marker = "." - markersize = 5 - end - if contains(label, "LLT") - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => raw"~") - label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") - label = raw"$" * label * raw"$" - else - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => "~") - label = replace(label, "VSM" => raw"\mathrm{VSM}") - label = raw"$" * label * raw"$" - end - axs[1, 1].plot( - polar_data[1], - polar_data[2], - label=label, - linestyle=linestyle, - marker=marker, - markersize=markersize, - ) - # Limit y-range if CL > 10 - if maximum(polar_data[2]) > 10 - axs[1, 1].set_ylim([-0.5, 2]) - end - title = raw"$C_\mathrm{L}" * raw"$" * " vs $angle_type [°]" - axs[1, 1].set_title(title) - axs[1, 1].set_xlabel("$angle_type [°]") - axs[1, 1].set_ylabel(L"$C_\mathrm{L}$") - axs[1, 1].legend() - end - - for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) - if i < n_solvers - linestyle = "-" - marker = "*" - markersize = 7 - else - linestyle = "-" - marker = "." - markersize = 5 - end - if contains(label, "LLT") - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => raw"~") - label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") - label = raw"$" * label * raw"$" - else - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => "~") - label = replace(label, "VSM" => raw"\mathrm{VSM}") - label = raw"$" * label * raw"$" - end - axs[1, 2].plot( - polar_data[1], - polar_data[3], - label=label, - linestyle=linestyle, - marker=marker, - markersize=markersize, - ) - # Limit y-range if CL > 10 - if maximum(polar_data[2]) > 10 - axs[1, 2].set_ylim([-0.5, 2]) - end - title = raw"$C_\mathrm{D}" * raw"$" * " vs $angle_type [°]" - axs[1, 2].set_title(title) - axs[1, 2].set_xlabel("$angle_type [°]") - axs[1, 2].set_ylabel(L"$C_\mathrm{D}$") - axs[1, 2].legend() - end - - - for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) - if i < n_solvers - linestyle = "-" - marker = "*" - markersize = 7 - else - linestyle = "-" - marker = "." - markersize = 5 - end - if contains(label, "LLT") - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => raw"~") - label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") - label = raw"$" * label * raw"$" - else - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => "~") - label = replace(label, "VSM" => raw"\mathrm{VSM}") - label = raw"$" * label * raw"$" - end - axs[2, 1].plot( - polar_data[1], - polar_data[4], - label=label, - linestyle=linestyle, - marker=marker, - markersize=markersize, - ) - # Limit y-range if CL > 10 - if maximum(polar_data[2]) > 10 - axs[2, 1].set_ylim([-0.5, 2]) - end - title = raw"$C_\mathrm{S}" * raw"$" * " vs $angle_type [°]" - axs[2, 1].set_title(title) - axs[2, 1].set_xlabel("$angle_type [°]") - axs[2, 1].set_ylabel(L"$C_\mathrm{S}$") - axs[2, 1].legend() - end - - for (i, (polar_data, label)) in enumerate(zip(polar_data_list, label_list)) - if i < n_solvers - linestyle = "-" - marker = "*" - markersize = 7 - else - linestyle = "-" - marker = "." - markersize = 5 - end - if contains(label, "LLT") - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => raw"~") - label = replace(label, "LLT" => raw"\mathrm{LLT}{~\,}") - label = raw"$" * label * raw"$" - else - label = replace(label, "e5" => raw"\cdot10^5") - label = replace(label, " " => "~") - label = replace(label, "VSM" => raw"\mathrm{VSM}") - label = raw"$" * label * raw"$" - end - axs[2, 2].plot( - polar_data[3], - polar_data[2], - label=label, - linestyle=linestyle, - marker=marker, - markersize=markersize, - ) - # Limit y-range if CL > 10 - if maximum(polar_data[2]) > 10 || maximum(polar_data[3]) > 10 - axs[2, 2].set_ylim([-0.5, 2]) - axs[2, 2].set_xlim([-0.5, 2]) - end - title = raw"$C_\mathrm{L}" * raw"$" * " vs " * raw"$C_\mathrm{D}" * raw"$" - axs[2, 2].set_title(title) - axs[2, 2].set_xlabel(L"$C_\mathrm{D}$") - axs[2, 2].set_ylabel(L"$C_\mathrm{L}$") - axs[2, 2].legend() - end - - fig.tight_layout(h_pad=3.5, rect=(0.01, 0.01, 0.99, 0.99)) - - # Save and show plot - if is_save && !isnothing(save_path) - save_plot(fig, save_path, main_title; data_type) - end - - if is_show - show_plot(fig) - end - - return fig -end - -""" - plot_polar_data(body_aero::BodyAerodynamics; alphas=collect(deg2rad.(-5:0.3:25)), delta_tes=collect(deg2rad.(-5:0.3:25))) - -Plot polar data (Cl, Cd, Cm) as 3D surfaces against alpha and delta_te angles. delta_te is the trailing edge deflection angle -relative to the 2d airfoil or panel chord line. - -# Arguments -- `body_aero`: Wing aerodynamics struct - -# Keyword arguments -- `alphas`: Range of angle of attack values in radians (default: -5° to 25° in 0.3° steps) -- `delta_tes`: Range of trailing edge angles in radians (default: -5° to 25° in 0.3° steps) -- `is_show`: Whether to display plots (default: true) -- `use_tex`: if the external `pdflatex` command shall be used -""" -function VortexStepMethod.plot_polar_data(body_aero::BodyAerodynamics; - alphas=collect(deg2rad.(-5:0.3:25)), - delta_tes = collect(deg2rad.(-5:0.3:25)), - is_show = true, - use_tex = false - ) - if body_aero.panels[1].aero_model == POLAR_MATRICES - set_plot_style() - - # Create figure with subplots - fig = plt.figure(figsize=(15, 6)) - - # Get interpolation functions and labels - interp_data = [ - (body_aero.panels[1].cl_interp, L"$C_l$"), - (body_aero.panels[1].cd_interp, L"$C_d$"), - (body_aero.panels[1].cm_interp, L"$C_m$") - ] - - # Create each subplot - for (idx, (interp, label)) in enumerate(interp_data) - ax = fig.add_subplot(1, 3, idx, projection="3d") - - # Create interpolation matrix - interp_matrix = zeros(length(alphas), length(delta_tes)) - interp_matrix .= [interp(alpha, delta_te) for alpha in alphas, delta_te in delta_tes] - X = collect(delta_tes) .+ zeros(length(alphas))' - Y = collect(alphas)' .+ zeros(length(delta_tes)) - - # Plot surface - ax.plot_wireframe(X, Y, interp_matrix, - edgecolor="blue", - lw=0.5, - rstride=5, - cstride=5, - alpha=0.6) - - # Set labels and title - ax.set_xlabel(L"$\delta$ [rad]") - ax.set_ylabel(L"$\alpha$ [rad]") - ax.set_zlabel(label) - ax.set_title(label * L" vs $\alpha$ and $\delta$") - ax.grid(true) - end - - # Adjust layout and display - plt.tight_layout(rect=(0.01, 0.01, 0.99, 0.99)) - if is_show - show_plot(fig) - end - return fig - else - throw(ArgumentError("Plotting polar data for $(body_aero.panels[1].aero_model) is not implemented.")) - end -end diff --git a/src/precompile.jl b/src/precompile.jl index cd9a44ab..aefd41f6 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -26,6 +26,7 @@ INVISCID) # Step 3: Initialize aerodynamics (simplified) + refine!(wing) body_aero = BodyAerodynamics([wing]) nothing diff --git a/src/settings.jl b/src/settings.jl index fe538ed9..9c6bd81f 100644 --- a/src/settings.jl +++ b/src/settings.jl @@ -8,8 +8,9 @@ end @with_kw mutable struct WingSettings name::String = "main_wing" geometry_file::String = "" # path to wing geometry YAML file + obj_file::String = "" # path to .obj geometry file + dat_file::String = "" # path to .dat airfoil file n_panels::Int64 = 40 - n_groups::Int64 = 40 spanwise_panel_distribution::PanelDistribution = LINEAR spanwise_direction::MVec3 = [0.0, 1.0, 0.0] remove_nan = true @@ -17,7 +18,6 @@ end @with_kw mutable struct SolverSettings n_panels::Int64 = 40 - n_groups::Int64 = 40 aerodynamic_model_type::Model = VSM solver_type::String = "LOOP" # type of solver density::Float64 = 1.225 # air density [kg/m³] @@ -32,6 +32,7 @@ end core_radius_fraction::Float64 = 1e-20 mu::Float64 = 1.81e-5 # dynamic viscosity [N·s/m²] calc_only_f_and_gamma::Bool=false # whether to only output f and gamma + correct_aoa::Bool=false # perform aoa correction end @Base.kwdef mutable struct VSMSettings @@ -40,9 +41,13 @@ end solver_settings::SolverSettings = SolverSettings() end -function VSMSettings(filename) +function VSMSettings(filename; data_prefix=true) # Uwe's suggested 3-line approach using StructMapping.jl (adapted) - data = YAML.load_file(joinpath("data", filename)) + if data_prefix + data = YAML.load_file(joinpath("data", filename)) + else + data = YAML.load_file(filename) + end # Use StructMapping for basic structure conversion # But handle special fields manually due to enum conversion needs @@ -55,8 +60,7 @@ function VSMSettings(filename) # Convert wing settings manually due to enum conversions n_panels = 0 - n_groups = 0 - + if haskey(data, "wings") for wing_data in data["wings"] wing = WingSettings() @@ -64,15 +68,26 @@ function VSMSettings(filename) if haskey(wing_data, "geometry_file") wing.geometry_file = wing_data["geometry_file"] end + if haskey(wing_data, "obj_file") + wing.obj_file = wing_data["obj_file"] + end + if haskey(wing_data, "dat_file") + wing.dat_file = wing_data["dat_file"] + end wing.n_panels = wing_data["n_panels"] - wing.n_groups = wing_data["n_groups"] + # Handle deprecated n_groups parameter + if haskey(wing_data, "n_groups") + @warn "n_groups in settings file is deprecated and ignored. Use n_unrefined_sections or let it be inferred automatically." maxlog=1 + end wing.spanwise_panel_distribution = eval(Symbol(wing_data["spanwise_panel_distribution"])) wing.spanwise_direction = MVec3(wing_data["spanwise_direction"]) + if haskey(wing_data, "grouping_method") + @warn "grouping_method in settings file is deprecated and ignored." maxlog=1 + end wing.remove_nan = wing_data["remove_nan"] - + push!(vsm_settings.wings, wing) n_panels += wing.n_panels - n_groups += wing.n_groups end end @@ -91,10 +106,14 @@ function VSMSettings(filename) # Handle enum conversions manually vsm_settings.solver_settings.aerodynamic_model_type = eval(Symbol(solver_data["aerodynamic_model_type"])) vsm_settings.solver_settings.type_initial_gamma_distribution = eval(Symbol(solver_data["type_initial_gamma_distribution"])) - + + # Set correct_aoa default based on model type if not explicitly provided + if !haskey(solver_data, "correct_aoa") + vsm_settings.solver_settings.correct_aoa = (vsm_settings.solver_settings.aerodynamic_model_type == VSM) + end + # Override with calculated totals vsm_settings.solver_settings.n_panels = n_panels - vsm_settings.solver_settings.n_groups = n_groups end return vsm_settings @@ -110,4 +129,4 @@ function Base.show(io::IO, vsm_settings::VSMSettings) print(io, replace(repr(wing), "\n" => "\n ")) end print(io, replace(repr(vsm_settings.solver_settings), "\n" => "\n ")) -end \ No newline at end of file +end diff --git a/src/solver.jl b/src/solver.jl index ecf34e3b..88e997a2 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -4,15 +4,19 @@ Struct for storing the solution of the [solve!](@ref) function. Must contain all info needed by `KiteModels.jl`. +# Naming Convention +- Variables ending in `_dist`: Per-panel distributions (length P, one value per panel) +- Variables ending in `_unrefined_dist`: Per-unrefined-section distributions (length U, averaged values per unrefined section) + # Attributes -- `panel_width_array`::Vector{Float64}: Width of the panels [m] -- `alpha_array`::Vector{Float64}: Angle of attack of each panel relative to the apparent wind [rad] -- cl_array::Vector{Float64}: Lift coefficients of the panels [-] -- cd_array::Vector{Float64}: Drag coefficients of the panels [-] -- cm_array::Vector{Float64}: Pitching moment coefficients of the panels [-] -- panel_lift::Vector{Float64}: Lift force of the panels [N] -- panel_drag::Vector{Float64}: Drag force of the panels [N] -- panel_moment::Vector{Float64}: Pitching moment around the spanwise vector of the panels [Nm] +- `width_dist`::Vector{Float64}: Width of the panels [m] +- `alpha_dist`::Vector{Float64}: Angle of attack of each panel relative to the apparent wind [rad] +- cl_dist::Vector{Float64}: Lift coefficients of the panels [-] +- cd_dist::Vector{Float64}: Drag coefficients of the panels [-] +- cm_dist::Vector{Float64}: Pitching moment coefficients of the panels [-] +- lift_dist::Vector{Float64}: Lift force of the panels [N] +- drag_dist::Vector{Float64}: Drag force of the panels [N] +- panel_moment_dist::Vector{Float64}: Pitching moment around the spanwise vector of the panels [Nm] - `f_body_3D`::Matrix{Float64}: Matrix of the aerodynamic forces (x, y, z vectors) [N] - `m_body_3D`::Matrix{Float64}: Matrix of the aerodynamic moments [Nm] - `gamma_distribution`::Union{Nothing, Vector{Float64}}: Vector containing the panel circulations. @@ -22,24 +26,29 @@ Struct for storing the solution of the [solve!](@ref) function. Must contain all - `moment_coeffs`::MVec3: Aerodynamic moment coefficients [CMx, CMy, CMz] [-] - `moment_dist`::Vector{Float64}: Pitching moments around the spanwise vector of each panel. [Nm] - `moment_coeff_dist`::Vector{Float64}: Pitching moment coefficient around the spanwise vector of each panel. [-] +- `moment_unrefined_dist`::MVector{U, Float64}: Averaged moments for unrefined sections [Nm] +- `cl_unrefined_dist`::MVector{U, Float64}: Averaged lift coefficients for unrefined sections [-] +- `cd_unrefined_dist`::MVector{U, Float64}: Averaged drag coefficients for unrefined sections [-] +- `cm_unrefined_dist`::MVector{U, Float64}: Averaged moment coefficients for unrefined sections [-] +- `alpha_unrefined_dist`::MVector{U, Float64}: Averaged angles of attack for unrefined sections [rad] - `solver_status`::SolverStatus: enum, see [SolverStatus](@ref) """ -@with_kw mutable struct VSMSolution{P,G} +@with_kw mutable struct VSMSolution{P,U} ### private vectors of solve_base! - _x_airf_array::Matrix{Float64} = zeros(P, 3) - _y_airf_array::Matrix{Float64} = zeros(P, 3) - _z_airf_array::Matrix{Float64} = zeros(P, 3) - _va_array::Matrix{Float64} = zeros(P, 3) - _chord_array::Vector{Float64} = zeros(P) + _x_airf_dist::Matrix{Float64} = zeros(P, 3) + _y_airf_dist::Matrix{Float64} = zeros(P, 3) + _z_airf_dist::Matrix{Float64} = zeros(P, 3) + _va_dist::Matrix{Float64} = zeros(P, 3) + _chord_dist::Vector{Float64} = zeros(P) ### end of private vectors - panel_width_array::Vector{Float64} = zeros(P) - alpha_array::Vector{Float64} = zeros(P) - cl_array::Vector{Float64} = zeros(P) - cd_array::Vector{Float64} = zeros(P) - cm_array::Vector{Float64} = zeros(P) - panel_lift::Vector{Float64} = zeros(P) - panel_drag::Vector{Float64} = zeros(P) - panel_moment::Vector{Float64} = zeros(P) + width_dist::Vector{Float64} = zeros(P) + alpha_dist::Vector{Float64} = zeros(P) + cl_dist::Vector{Float64} = zeros(P) + cd_dist::Vector{Float64} = zeros(P) + cm_dist::Vector{Float64} = zeros(P) + lift_dist::Vector{Float64} = zeros(P) + drag_dist::Vector{Float64} = zeros(P) + panel_moment_dist::Vector{Float64} = zeros(P) f_body_3D::Matrix{Float64} = zeros(3, P) m_body_3D::Matrix{Float64} = zeros(3, P) gamma_distribution::Union{Nothing, Vector{Float64}} = nothing @@ -49,8 +58,17 @@ Struct for storing the solution of the [solve!](@ref) function. Must contain all moment_coeffs::MVec3 = zeros(MVec3) moment_dist::MVector{P, Float64} = zeros(P) moment_coeff_dist::MVector{P, Float64} = zeros(P) - group_moment_dist::MVector{G, Float64} = zeros(G) - group_moment_coeff_dist::MVector{G, Float64} = zeros(G) + moment_unrefined_dist::MVector{U, Float64} = zeros(U) + cl_unrefined_dist::MVector{U, Float64} = zeros(U) + cd_unrefined_dist::MVector{U, Float64} = zeros(U) + cm_unrefined_dist::MVector{U, Float64} = zeros(U) + alpha_unrefined_dist::MVector{U, Float64} = zeros(U) + x_airf_unrefined_dist::Vector{MVec3} = [MVec3(0,0,0) for _ in 1:U] + y_airf_unrefined_dist::Vector{MVec3} = [MVec3(0,0,0) for _ in 1:U] + z_airf_unrefined_dist::Vector{MVec3} = [MVec3(0,0,0) for _ in 1:U] + va_unrefined_dist::Vector{MVec3} = [MVec3(0,0,0) for _ in 1:U] + chord_unrefined_dist::MVector{U, Float64} = zeros(U) + width_unrefined_dist::MVector{U, Float64} = zeros(U) solver_status::SolverStatus = FAILURE end @@ -58,13 +76,13 @@ end @with_kw mutable struct LoopResult{P} converged::Bool = false gamma_new::MVector{P, Float64} = zeros(P) - alpha_array::MVector{P, Float64} = zeros(P) # TODO: Is this different from BodyAerodynamics.alpha_array ? - v_a_array::MVector{P, Float64} = zeros(P) + alpha_dist::MVector{P, Float64} = zeros(P) # TODO: Is this different from BodyAerodynamics.alpha_dist ? + v_a_dist::MVector{P, Float64} = zeros(P) end @with_kw struct BaseResult{P} - va_norm_array::MVector{P, Float64} = zeros(P) - va_unit_array::Matrix{Float64} = zeros(P, 3) + va_norm_dist::MVector{P, Float64} = zeros(P) + va_unit_dist::Matrix{Float64} = zeros(P, 3) end """ @@ -95,7 +113,7 @@ Main solver structure for the Vortex Step Method.See also: [solve](@ref) ## Solution sol::VSMSolution = VSMSolution(): The result of calling [solve!](@ref) """ -@with_kw mutable struct Solver{P,G} +@with_kw mutable struct Solver{P,U} # General settings solver_type::SolverType = LOOP aerodynamic_model_type::Model = VSM @@ -119,6 +137,7 @@ sol::VSMSolution = VSMSolution(): The result of calling [solve!](@ref) core_radius_fraction::Float64 = 1e-20 mu::Float64 = 1.81e-5 is_only_f_and_gamma_output::Bool = false + correct_aoa::Bool = false # Intermediate results lr::LoopResult{P} = LoopResult{P}() @@ -128,13 +147,13 @@ sol::VSMSolution = VSMSolution(): The result of calling [solve!](@ref) cache_lin::Vector{PreallocationTools.LazyBufferCache{typeof(identity), typeof(identity)}} = [LazyBufferCache() for _ in 1:4] # Solution - sol::VSMSolution{P,G} = VSMSolution{P,G}() + sol::VSMSolution{P,U} = VSMSolution{P,U}() end function Solver(body_aero; kwargs...) P = length(body_aero.panels) - G = sum([wing.n_groups for wing in body_aero.wings]) - return Solver{P,G}(; kwargs...) + U = sum([wing.n_unrefined_sections for wing in body_aero.wings]) + return Solver{P,U}(; kwargs...) end function Solver(body_aero, settings::VSMSettings) @@ -145,6 +164,7 @@ function Solver(body_aero, settings::VSMSettings) rtol=settings.solver_settings.rtol, relaxation_factor=settings.solver_settings.relaxation_factor, core_radius_fraction=settings.solver_settings.core_radius_fraction, + correct_aoa=settings.solver_settings.correct_aoa, ) end @@ -182,16 +202,16 @@ function solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution= end # Initialize arrays - cl_array = solver.sol.cl_array - cd_array = solver.sol.cd_array - cm_array = solver.sol.cm_array + cl_dist = solver.sol.cl_dist + cd_dist = solver.sol.cd_dist + cm_dist = solver.sol.cm_dist converged = solver.lr.converged - alpha_array = solver.lr.alpha_array - alpha_corrected = solver.sol.alpha_array - v_a_array = solver.lr.v_a_array + alpha_dist = solver.lr.alpha_dist + alpha_corrected = solver.sol.alpha_dist + v_a_dist = solver.lr.v_a_dist panels = body_aero.panels - panel_width_array = solver.sol.panel_width_array + width_dist = solver.sol.width_dist solver.sol.moment_dist .= 0 solver.sol.moment_coeff_dist .= 0 moment_dist = solver.sol.moment_dist @@ -201,20 +221,20 @@ function solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution= # Calculate coefficients for each panel for (i, panel) in enumerate(panels) # zero bytes - cl_array[i] = calculate_cl(panel, alpha_array[i]) - cd_array[i], cm_array[i] = calculate_cd_cm(panel, alpha_array[i]) - panel_width_array[i] = panel.width + cl_dist[i] = calculate_cl(panel, alpha_dist[i]) + cd_dist[i], cm_dist[i] = calculate_cd_cm(panel, alpha_dist[i]) + width_dist[i] = panel.width end # create an alias for the three vertical output vectors - lift = solver.sol.panel_lift - drag = solver.sol.panel_drag - panel_moment = solver.sol.panel_moment + lift = solver.sol.lift_dist + drag = solver.sol.drag_dist + panel_moment_dist = solver.sol.panel_moment_dist # Compute using fused broadcasting (no intermediate allocations) - @. lift = cl_array * 0.5 * density * v_a_array^2 * solver.sol._chord_array - @. drag = cd_array * 0.5 * density * v_a_array^2 * solver.sol._chord_array - @. panel_moment = cm_array * 0.5 * density * v_a_array^2 * solver.sol._chord_array + @. lift = cl_dist * 0.5 * density * v_a_dist^2 * solver.sol._chord_dist + @. drag = cd_dist * 0.5 * density * v_a_dist^2 * solver.sol._chord_dist + @. panel_moment_dist = cm_dist * 0.5 * density * v_a_dist^2 * solver.sol._chord_dist # Calculate alpha corrections based on model type if aerodynamic_model_type == VSM # 64 bytes @@ -223,14 +243,14 @@ function solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution= body_aero, gamma_new, solver.core_radius_fraction, - solver.sol._z_airf_array, - solver.sol._x_airf_array, - solver.sol._va_array, - solver.br.va_norm_array, - solver.br.va_unit_array + solver.sol._z_airf_dist, + solver.sol._x_airf_dist, + solver.sol._va_dist, + solver.br.va_norm_dist, + solver.br.va_unit_dist ) elseif aerodynamic_model_type == LLT - alpha_corrected .= alpha_array + alpha_corrected .= alpha_dist end # Initialize result arrays @@ -278,7 +298,7 @@ function solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution= # Use the axis around which the moment is defined, # which is the y-axis pointing "spanwise" moment_axis_body = panel.y_airf - M_local_3D = panel_moment[i] * moment_axis_body * panel.width + M_local_3D = panel_moment_dist[i] * moment_axis_body * panel.width # Vector from panel AC to the chosen reference point: r_vector = panel_ac_body - reference_point # e.g. CG, wing root, etc. # Cross product to shift the force from panel AC to ref. point: @@ -288,25 +308,91 @@ function solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution= # Calculate the moment distribution (moment on each panel) arm = (moment_frac - 0.25) * panel.chord - moment_dist[i] = ((ftotal_induced_va ⋅ panel.z_airf) * arm + panel_moment[i]) * panel.width + moment_dist[i] = ((ftotal_induced_va ⋅ panel.z_airf) * arm + panel_moment_dist[i]) * panel.width moment_coeff_dist[i] = moment_dist[i] / (q_inf * projected_area) end - group_moment_dist = solver.sol.group_moment_dist - group_moment_coeff_dist = solver.sol.group_moment_coeff_dist - group_moment_dist .= 0.0 - group_moment_coeff_dist .= 0.0 - panel_idx = 1 - group_idx = 1 - for wing in body_aero.wings - panels_per_group = wing.n_panels ÷ wing.n_groups - for _ in 1:wing.n_groups - for _ in 1:panels_per_group - group_moment_dist[group_idx] += moment_dist[panel_idx] - group_moment_coeff_dist[group_idx] += moment_coeff_dist[panel_idx] - panel_idx += 1 + # Only compute unrefined arrays if there are unrefined sections + if length(solver.sol.moment_unrefined_dist) > 0 + moment_unrefined_dist = solver.sol.moment_unrefined_dist + cl_unrefined_dist = solver.sol.cl_unrefined_dist + cd_unrefined_dist = solver.sol.cd_unrefined_dist + cm_unrefined_dist = solver.sol.cm_unrefined_dist + alpha_unrefined_dist = solver.sol.alpha_unrefined_dist + x_airf_unrefined_dist = solver.sol.x_airf_unrefined_dist + y_airf_unrefined_dist = solver.sol.y_airf_unrefined_dist + z_airf_unrefined_dist = solver.sol.z_airf_unrefined_dist + va_unrefined_dist = solver.sol.va_unrefined_dist + chord_unrefined_dist = solver.sol.chord_unrefined_dist + width_unrefined_dist = solver.sol.width_unrefined_dist + + # Zero all unrefined arrays + moment_unrefined_dist .= 0.0 + cl_unrefined_dist .= 0.0 + cd_unrefined_dist .= 0.0 + cm_unrefined_dist .= 0.0 + alpha_unrefined_dist .= 0.0 + for i in eachindex(x_airf_unrefined_dist) + x_airf_unrefined_dist[i] .= 0.0 + y_airf_unrefined_dist[i] .= 0.0 + z_airf_unrefined_dist[i] .= 0.0 + va_unrefined_dist[i] .= 0.0 + end + chord_unrefined_dist .= 0.0 + width_unrefined_dist .= 0.0 + + panel_idx = 1 + unrefined_idx = 1 + for wing in body_aero.wings + if wing.n_unrefined_sections > 0 + # Accumulate values from refined panels to unrefined sections + unrefined_section_counts = zeros(Int, wing.n_unrefined_sections) + for local_panel_idx in 1:wing.n_panels + panel = body_aero.panels[panel_idx] + original_section_idx = wing.refined_panel_mapping[local_panel_idx] + target_unrefined_idx = unrefined_idx + original_section_idx - 1 + + # Accumulate coefficients and moments + moment_unrefined_dist[target_unrefined_idx] += moment_dist[panel_idx] + cl_unrefined_dist[target_unrefined_idx] += solver.sol.cl_dist[panel_idx] + cd_unrefined_dist[target_unrefined_idx] += solver.sol.cd_dist[panel_idx] + cm_unrefined_dist[target_unrefined_idx] += solver.sol.cm_dist[panel_idx] + alpha_unrefined_dist[target_unrefined_idx] += solver.sol.alpha_dist[panel_idx] + + # Accumulate geometry + x_airf_unrefined_dist[target_unrefined_idx] .+= panel.x_airf + y_airf_unrefined_dist[target_unrefined_idx] .+= panel.y_airf + z_airf_unrefined_dist[target_unrefined_idx] .+= panel.z_airf + va_unrefined_dist[target_unrefined_idx] .+= panel.va + chord_unrefined_dist[target_unrefined_idx] += panel.chord + width_unrefined_dist[target_unrefined_idx] += panel.width + + unrefined_section_counts[original_section_idx] += 1 + panel_idx += 1 + end + + # Average coefficients and geometry + for i in 1:wing.n_unrefined_sections + target_unrefined_idx = unrefined_idx + i - 1 + if unrefined_section_counts[i] > 0 + count = unrefined_section_counts[i] + moment_unrefined_dist[target_unrefined_idx] /= count + cl_unrefined_dist[target_unrefined_idx] /= count + cd_unrefined_dist[target_unrefined_idx] /= count + cm_unrefined_dist[target_unrefined_idx] /= count + alpha_unrefined_dist[target_unrefined_idx] /= count + x_airf_unrefined_dist[target_unrefined_idx] ./= count + y_airf_unrefined_dist[target_unrefined_idx] ./= count + z_airf_unrefined_dist[target_unrefined_idx] ./= count + va_unrefined_dist[target_unrefined_idx] ./= count + chord_unrefined_dist[target_unrefined_idx] /= count + end + end + unrefined_idx += wing.n_unrefined_sections + else + # Skip panels for wings with no unrefined sections + panel_idx += wing.n_panels end - group_idx += 1 end end @@ -366,24 +452,25 @@ function solve(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution=n solver.aerodynamic_model_type, solver.core_radius_fraction, solver.mu, - solver.lr.alpha_array, - solver.lr.v_a_array, - solver.sol._chord_array, - solver.sol._x_airf_array, - solver.sol._y_airf_array, - solver.sol._z_airf_array, - solver.sol._va_array, - solver.br.va_norm_array, - solver.br.va_unit_array, + solver.lr.alpha_dist, + solver.lr.v_a_dist, + solver.sol._chord_dist, + solver.sol._x_airf_dist, + solver.sol._y_airf_dist, + solver.sol._z_airf_dist, + solver.sol._va_dist, + solver.br.va_norm_dist, + solver.br.va_unit_dist, body_aero.panels, - solver.is_only_f_and_gamma_output + solver.is_only_f_and_gamma_output; + correct_aoa=solver.correct_aoa ) return results end -@inline @inbounds function calc_norm_array!(va_norm_array, va_array) +@inline @inbounds function calc_norm_array!(va_norm_dist, va_array) for i in 1:size(va_array, 1) - va_norm_array[i] = norm(MVec3(view(va_array, i, :))) + va_norm_dist[i] = norm(MVec3(view(va_array, i, :))) end end @@ -399,31 +486,31 @@ function solve_base!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribu relaxation_factor = solver.relaxation_factor # Clear arrays - solver.sol._x_airf_array .= 0 - solver.sol._y_airf_array .= 0 - solver.sol._z_airf_array .= 0 - solver.sol._va_array .= 0 - solver.sol._chord_array .= 0 + solver.sol._x_airf_dist .= 0 + solver.sol._y_airf_dist .= 0 + solver.sol._z_airf_dist .= 0 + solver.sol._va_dist .= 0 + solver.sol._chord_dist .= 0 # Fill arrays from panels for (i, panel) in enumerate(panels) - solver.sol._x_airf_array[i, :] .= panel.x_airf - solver.sol._y_airf_array[i, :] .= panel.y_airf - solver.sol._z_airf_array[i, :] .= panel.z_airf - solver.sol._va_array[i, :] .= panel.va - solver.sol._chord_array[i] = panel.chord + solver.sol._x_airf_dist[i, :] .= panel.x_airf + solver.sol._y_airf_dist[i, :] .= panel.y_airf + solver.sol._z_airf_dist[i, :] .= panel.z_airf + solver.sol._va_dist[i, :] .= panel.va + solver.sol._chord_dist[i] = panel.chord end # Calculate unit vectors - calc_norm_array!(solver.br.va_norm_array, solver.sol._va_array) - solver.br.va_unit_array .= solver.sol._va_array ./ solver.br.va_norm_array + calc_norm_array!(solver.br.va_norm_dist, solver.sol._va_dist) + solver.br.va_unit_dist .= solver.sol._va_dist ./ solver.br.va_norm_dist # Calculate AIC matrices - calculate_AIC_matrices!(body_aero, solver.aerodynamic_model_type, solver.core_radius_fraction, solver.br.va_norm_array, - solver.br.va_unit_array) + calculate_AIC_matrices!(body_aero, solver.aerodynamic_model_type, solver.core_radius_fraction, solver.br.va_norm_dist, + solver.br.va_unit_dist) # Initialize gamma distribution - gamma_initial = solver.cache_base[1][solver.sol._chord_array] + gamma_initial = solver.cache_base[1][solver.sol._chord_dist] if isnothing(gamma_distribution) if solver.type_initial_gamma_distribution == ELLIPTIC calculate_circulation_distribution_elliptical_wing(gamma_initial, body_aero) @@ -464,24 +551,24 @@ function gamma_loop!( relaxation_factor::Float64; log::Bool = true ) - va_array = solver.sol._va_array - chord_array = solver.sol._chord_array - x_airf_array = solver.sol._x_airf_array - y_airf_array = solver.sol._y_airf_array - z_airf_array = solver.sol._z_airf_array + va_array = solver.sol._va_dist + chord_array = solver.sol._chord_dist + x_airf_array = solver.sol._x_airf_dist + y_airf_array = solver.sol._y_airf_dist + z_airf_array = solver.sol._z_airf_dist solver.lr.converged = false n_panels = length(body_aero.panels) - solver.lr.alpha_array .= body_aero.alpha_array - solver.lr.v_a_array .= body_aero.v_a_array + solver.lr.alpha_dist .= body_aero.alpha_dist + solver.lr.v_a_dist .= body_aero.v_a_dist - va_magw_array = solver.cache[1][solver.lr.v_a_array] + va_magw_array = solver.cache[1][solver.lr.v_a_dist] gamma = solver.cache[2][solver.lr.gamma_new] abs_gamma_new = solver.cache[3][solver.lr.gamma_new] induced_velocity_all = solver.cache[4][va_array] relative_velocity_array = solver.cache[5][va_array] relative_velocity_crossz = solver.cache[6][va_array] v_acrossz_array = solver.cache[7][va_array] - cl_array = solver.cache[8][solver.lr.gamma_new] + cl_dist = solver.cache[8][solver.lr.gamma_new] damp = solver.cache[9][solver.lr.gamma_new] v_normal_array = solver.cache[10][solver.lr.gamma_new] v_tangential_array = solver.cache[11][solver.lr.gamma_new] @@ -512,17 +599,17 @@ function gamma_loop!( v_normal_array[i] = view(z_airf_array, i, :) ⋅ view(relative_velocity_array, i, :) v_tangential_array[i] = view(x_airf_array, i, :) ⋅ view(relative_velocity_array, i, :) end - solver.lr.alpha_array .= atan.(v_normal_array, v_tangential_array) + solver.lr.alpha_dist .= atan.(v_normal_array, v_tangential_array) for i in 1:n_panels - @views solver.lr.v_a_array[i] = norm(relative_velocity_crossz[i, :]) + @views solver.lr.v_a_dist[i] = norm(relative_velocity_crossz[i, :]) @views va_magw_array[i] = norm(v_acrossz_array[i, :]) end - for (i, (panel, alpha)) in enumerate(zip(panels, solver.lr.alpha_array)) - cl_array[i] = calculate_cl(panel, alpha) + for (i, (panel, alpha)) in enumerate(zip(panels, solver.lr.alpha_dist)) + cl_dist[i] = calculate_cl(panel, alpha) end - gamma_new .= 0.5 .* solver.lr.v_a_array.^2 ./ va_magw_array .* cl_array .* chord_array + gamma_new .= 0.5 .* solver.lr.v_a_dist.^2 ./ va_magw_array .* cl_dist .* chord_array nothing end @@ -646,51 +733,69 @@ function smooth_circulation!( end """ - linearize(solver::Solver, body_aero::BodyAerodynamics, wing::RamAirWing, y::Vector{T}; - theta_idxs=1:4, delta_idxs=nothing, va_idxs=nothing, omega_idxs=nothing, kwargs...) where T + linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; + theta_idxs=1:4, delta_idxs=nothing, va_idxs=nothing, omega_idxs=nothing, + aero_coeffs=false, kwargs...) where T -Compute the Jacobian matrix for a ram air wing around an operating point using finite differences. +Compute Jacobian matrix of aerodynamic outputs with respect to control and kinematic inputs using +finite differences. Used for control system design and linear stability analysis. + +The function uses automatic differentiation with finite differences to compute ∂outputs/∂inputs. +Deformations are applied to the wing's unrefined sections (the original sections before mesh +refinement), with each control angle affecting one unrefined section. # Arguments -- `solver`: VSM solver instance (must be initialized) -- `body_aero`: Aerodynamic body representation -- `wing`: RamAirWing model to linearize -- `y`: Input vector at operating point, containing a combination of control angles and velocities +- `solver::Solver`: Solver instance (must be configured for the wing) +- `body_aero::BodyAerodynamics`: Body aerodynamics with exactly one wing +- `y::Vector{T}`: Input vector at operating point containing control angles and/or kinematic states # Keyword Arguments -- `theta_idxs`: Indices of twist angles in input vector (default: 1:4) -- `delta_idxs`: Indices of trailing edge deflection angles (default: nothing) -- `va_idxs`: Indices of velocity components `[vx, vy, vz]` (default: nothing) -- `omega_idxs`: Indices of angular velocity components `[ωx, ωy, ωz]` (default: nothing) -- `kwargs...`: Additional arguments passed to the `solve!` function +- `theta_idxs`: Indices in `y` for twist angles (one per unrefined section, default: 1:4) +- `delta_idxs`: Indices in `y` for trailing edge deflections (one per unrefined section, default: nothing) +- `va_idxs`: Indices in `y` for apparent wind velocity [vx, vy, vz] (default: nothing) +- `omega_idxs`: Indices in `y` for angular velocity [ωx, ωy, ωz] (default: nothing) +- `aero_coeffs::Bool`: Return force/moment coefficients instead of dimensional values (default: false) +- `kwargs...`: Additional arguments passed to `solve!` + +# Index Validation +The lengths of `theta_idxs` and `delta_idxs` (if provided) must match `wing.n_unrefined_sections`. +Unrefined sections are the original wing sections before mesh refinement for aerodynamic analysis. + +# Caching +The function caches previous deformation angles to avoid redundant `unrefined_deform!` calls during +Jacobian computation. When the same angles are encountered, geometry deformation is skipped. # Returns -- `jac`: Jacobian matrix (∂outputs/∂inputs) -- `results`: Output vector at the operating point [Fx, Fy, Fz, Mx, My, Mz, group_moments...] +- `jac::Matrix{Float64}`: Jacobian matrix (m×n) where m = 6 + n_unrefined_sections, n = length(y) +- `results::Vector{Float64}`: Output vector at operating point + - If `aero_coeffs=false`: [Fx, Fy, Fz, Mx, My, Mz, moment_unrefined_dist...] + - If `aero_coeffs=true`: [CFx, CFy, CFz, CMx, CMy, CMz, cm_unrefined_dist...] # Example ```julia -# Initialize wing and solver -wing = RamAirWing("path/to/body.obj", "path/to/foil.dat") -body_aero = BodyAerodynamics([wing]) +# Create deformable wing with 4 unrefined sections +wing = ObjWing("kite.obj", "airfoil.dat"; n_unrefined_sections=4) +body_aero = BodyAerodynamics([wing], va=[15.0, 0, 0]) solver = Solver(body_aero) -# Define operating point with 4 control angles, velocity, and angular rates -y_op = [zeros(4); # 4 twist control angles (rad) - [15.0, 0.0, 0.0]; # Velocity vector (m/s) - zeros(3)] # Angular velocity (rad/s) +# Operating point: 4 twist angles + velocity + angular rates +y_op = [zeros(4); # theta angles [rad] + [15.0, 0.0, 0.0]; # va [m/s] + zeros(3)] # omega [rad/s] # Compute Jacobian -jac, results = linearize( - solver, body_aero, wing, y_op; - theta_idxs=1:4, # Twist angles - va_idxs=5:7, # Velocity components - omega_idxs=8:10 # Angular rates +jac, results = linearize(solver, body_aero, y_op; + theta_idxs=1:4, + va_idxs=5:7, + omega_idxs=8:10, + aero_coeffs=true ) + +# jac is (10×10): [6 force/moment coeffs + 4 unrefined moment coeffs] × [4 theta + 3 va + 3 omega] ``` """ -function linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; - theta_idxs=1:4, +function linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; + theta_idxs=1:4, delta_idxs=nothing, va_idxs=nothing, omega_idxs=nothing, @@ -700,6 +805,19 @@ function linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; !(length(body_aero.wings) == 1) && throw(ArgumentError("Linearization only works for a body_aero with one wing")) wing = body_aero.wings[1] + # Validate that theta_idxs and delta_idxs match the number of unrefined sections + if !isnothing(theta_idxs) && wing.n_unrefined_sections > 0 + length(theta_idxs) != wing.n_unrefined_sections && throw(ArgumentError( + "Length of theta_idxs ($(length(theta_idxs))) must match number of unrefined sections ($(wing.n_unrefined_sections))")) + end + if !isnothing(delta_idxs) && wing.n_unrefined_sections > 0 + length(delta_idxs) != wing.n_unrefined_sections && throw(ArgumentError( + "Length of delta_idxs ($(length(delta_idxs))) must match number of unrefined sections ($(wing.n_unrefined_sections))")) + end + if wing.n_unrefined_sections == 0 && (!isnothing(theta_idxs) || !isnothing(delta_idxs)) + throw(ArgumentError("Cannot use theta_idxs or delta_idxs when wing has no unrefined sections")) + end + init_va = body_aero.cache[1][body_aero.va] init_va .= body_aero.va if !isnothing(theta_idxs) @@ -718,20 +836,20 @@ function linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; if !isnothing(theta_angles) && isnothing(delta_angles) if !all(theta_angles .== last_theta) - VortexStepMethod.group_deform!(wing, theta_angles, nothing; smooth=false) + VortexStepMethod.unrefined_deform!(wing, theta_angles, nothing; smooth=false) VortexStepMethod.reinit!(body_aero; init_aero=false) last_theta .= theta_angles end elseif !isnothing(theta_angles) && !isnothing(delta_angles) if !all(delta_angles .== last_delta) || !all(theta_angles .== last_theta) - VortexStepMethod.group_deform!(wing, theta_angles, delta_angles; smooth=false) + VortexStepMethod.unrefined_deform!(wing, theta_angles, delta_angles; smooth=false) VortexStepMethod.reinit!(body_aero; init_aero=false) last_theta .= theta_angles last_delta .= delta_angles end elseif isnothing(theta_angles) && !isnothing(delta_angles) if !all(delta_angles .== last_delta) - VortexStepMethod.group_deform!(wing, nothing, delta_angles; smooth=false) + VortexStepMethod.unrefined_deform!(wing, nothing, delta_angles; smooth=false) VortexStepMethod.reinit!(body_aero; init_aero=false) last_delta .= delta_angles end @@ -749,16 +867,16 @@ function linearize(solver::Solver, body_aero::BodyAerodynamics, y::Vector{T}; if !aero_coeffs results[1:3] .= solver.sol.force results[4:6] .= solver.sol.moment - results[7:end] .= solver.sol.group_moment_dist + results[7:end] .= solver.sol.moment_unrefined_dist else results[1:3] .= solver.sol.force_coeffs results[4:6] .= solver.sol.moment_coeffs - results[7:end] .= solver.sol.group_moment_coeff_dist + results[7:end] .= solver.sol.cm_unrefined_dist end return nothing end - results = zeros(3+3+length(solver.sol.group_moment_dist)) + results = zeros(3+3+length(solver.sol.moment_unrefined_dist)) jac = zeros(length(results), length(y)) backend = AutoFiniteDiff(absstep=1e2solver.atol, relstep=1e2solver.rtol) prep = prepare_jacobian(calc_results!, results, backend, y) diff --git a/src/wing_geometry.jl b/src/wing_geometry.jl index 506b91b4..241b2bc3 100644 --- a/src/wing_geometry.jl +++ b/src/wing_geometry.jl @@ -44,10 +44,12 @@ function reinit!(section::Section, LE_point, TE_point, aero_model=nothing, aero_ section.TE_point .= TE_point (!isnothing(aero_model)) && (section.aero_model = aero_model) if !isnothing(aero_data) - if !isnothing(section.aero_data) - section.aero_data .= aero_data - else + # NTuple is immutable, so we must assign directly + # For mutable types (Vector, Matrix), we can broadcast for efficiency + if aero_data isa NTuple || aero_data isa Tuple || isnothing(section.aero_data) section.aero_data = aero_data + else + section.aero_data .= aero_data end end nothing @@ -192,58 +194,123 @@ end Represents a wing composed of multiple sections with aerodynamic properties. -# Fields +# Core Fields (all wings) - `n_panels::Int16`: Number of panels in aerodynamic mesh -- `n_groups::Int16`: Number of panel groups +- `n_unrefined_sections::Int16`: Number of unrefined sections (sections before mesh refinement) - `spanwise_distribution`::PanelDistribution: [PanelDistribution](@ref) - `spanwise_direction::MVec3`: Wing span direction vector - `sections::Vector{Section}`: Vector of wing sections, see: [Section](@ref) - `refined_sections::Vector{Section}`: Vector of refined wing sections, see: [Section](@ref) - `remove_nan::Bool`: Wether to remove the NaNs from interpolations or not +# Deformation Fields (optional, for deformable wings) +- `non_deformed_sections::Vector{Section}`: Original undeformed sections +- `theta_dist::Vector{Float64}`: Panel twist angle distribution +- `delta_dist::Vector{Float64}`: Trailing edge deflection distribution + +# Physical Properties (optional, for OBJ-based wings) +- `mass::Float64`: Total wing mass in kg (0.0 if not applicable) +- `gamma_tip::Float64`: Angular extent from center to wing tip (0.0 if not applicable) +- `inertia_tensor::Matrix{Float64}`: 3x3 inertia tensor (empty if not applicable) +- `T_cad_body::MVec3`: Translation from CAD to body frame (zeros if not applicable) +- `R_cad_body::MMat3`: Rotation from CAD to body frame (identity if not applicable) +- `radius::Float64`: Wing curvature radius (0.0 if not applicable) +- `le_interp::Union{Nothing, NTuple{3, Extrapolation}}`: Leading edge interpolation +- `te_interp::Union{Nothing, NTuple{3, Extrapolation}}`: Trailing edge interpolation +- `area_interp::Union{Nothing, Extrapolation}`: Area interpolation +- `cache::Vector{PreallocationTools.LazyBufferCache{typeof(identity), typeof(identity)}}`: Preallocated buffers + """ mutable struct Wing <: AbstractWing n_panels::Int16 - n_groups::Int16 + n_unrefined_sections::Int16 spanwise_distribution::PanelDistribution panel_props::PanelProperties spanwise_direction::MVec3 - sections::Vector{Section} + unrefined_sections::Vector{Section} refined_sections::Vector{Section} remove_nan::Bool + + # Grouping + refined_panel_mapping::Vector{Int16} # Maps each refined panel index to unrefined section index (1 to n_unrefined_sections) + + # Deformation fields + non_deformed_sections::Vector{Section} + theta_dist::Vector{Float64} # Length: n_panels (panel twist angles) + delta_dist::Vector{Float64} # Length: n_panels (panel TE deflection angles) + + # Physical properties (OBJ-based wings) + mass::Float64 + gamma_tip::Float64 + inertia_tensor::Matrix{Float64} + T_cad_body::MVec3 + R_cad_body::MMat3 + radius::Float64 + le_interp::Union{Nothing, NTuple{3, Extrapolation}} + te_interp::Union{Nothing, NTuple{3, Extrapolation}} + area_interp::Union{Nothing, Extrapolation} + cache::Vector{PreallocationTools.LazyBufferCache{typeof(identity), typeof(identity)}} end """ Wing(n_panels::Int; - n_groups=n_panels, + n_unrefined_sections=nothing, spanwise_distribution::PanelDistribution=LINEAR, spanwise_direction::PosVector=MVec3([0.0, 1.0, 0.0]), remove_nan::Bool=true) -Constructor for a [Wing](@ref) struct with default values that initializes the sections -and refined sections as empty arrays. +Constructor for a [Wing](@ref) struct with default values that initializes the sections +and refined sections as empty arrays. Creates a basic wing suitable for YAML-based construction. # Parameters - `n_panels::Int`: Number of panels in aerodynamic mesh -- `n_groups::Int`: Number of panel groups in aerodynamic mesh +- `n_unrefined_sections::Int`: Number of unrefined sections (inferred from added sections for YAML wings) - `spanwise_distribution`::PanelDistribution = LINEAR: [PanelDistribution](@ref) - `spanwise_direction::MVec3` = MVec3([0.0, 1.0, 0.0]): Wing span direction vector - `remove_nan::Bool`: Wether to remove the NaNs from interpolations or not """ function Wing(n_panels::Int; - n_groups = n_panels, + n_unrefined_sections=nothing, spanwise_distribution::PanelDistribution=LINEAR, spanwise_direction::PosVector=MVec3([0.0, 1.0, 0.0]), remove_nan=true) - !(n_panels % n_groups == 0) && throw(ArgumentError("Number of panels should be divisible by number of groups")) + + # For YAML wings, n_unrefined_sections will be set when sections are added + # Set to 0 as placeholder for now + n_unrefined_sections_value = isnothing(n_unrefined_sections) ? Int16(0) : Int16(n_unrefined_sections) + panel_props = PanelProperties{n_panels}() - Wing(n_panels, n_groups, spanwise_distribution, panel_props, spanwise_direction, Section[], Section[], remove_nan) + + # Initialize with default/empty values for optional fields + Wing( + n_panels, n_unrefined_sections_value, spanwise_distribution, panel_props, spanwise_direction, + Section[], Section[], remove_nan, + # Grouping + Int16[], + # Deformation fields + Section[], zeros(max(0, n_panels)), zeros(max(0, n_panels)), + # Physical properties (defaults for non-OBJ wings) + 0.0, 0.0, zeros(0, 0), zeros(MVec3), Matrix{Float64}(I, 3, 3), + 0.0, nothing, nothing, nothing, + PreallocationTools.LazyBufferCache{typeof(identity), typeof(identity)}[] + ) end +""" + reinit!(wing::AbstractWing) + +Reinitialize wing panel properties based on current refined_sections geometry. + +This function only updates panel properties (chord, area, etc.) from the existing +refined_sections. It does NOT refine the mesh - call refine_aerodynamic_mesh!(wing) +first if needed. + +# Note +After deformation via `unrefined_deform!()` or `deform!()`, call `reinit!` to update +panel properties while preserving the deformed geometry. +""" function reinit!(wing::AbstractWing) - refine_aerodynamic_mesh!(wing) - - # Calculate panel properties + # Calculate panel properties from refined sections update_panel_properties!( wing.panel_props, wing.refined_sections, @@ -252,6 +319,224 @@ function reinit!(wing::AbstractWing) return nothing end +""" + unrefined_deform!(wing::Wing, theta_angles=nothing, delta_angles=nothing; smooth=false) + +Apply deformation angles directly to unrefined wing sections. + +For wings that support deformation (OBJ-based wings with non_deformed_sections), this +applies theta_angles and delta_angles directly to unrefined sections and then applies deformation. +For wings without deformation support (YAML-based), this is a no-op that only succeeds +if both angle inputs are nothing. + +# Arguments +- `wing::Wing`: The wing to deform +- `theta_angles::AbstractVector`: Twist angles in radians for each unrefined section (or nothing). + Length must be `n_unrefined_sections` +- `delta_angles::AbstractVector`: Trailing edge deflection angles in radians for each unrefined section (or nothing). + Length must be `n_unrefined_sections` +- `smooth::Bool`: DEPRECATED - no longer used. Apply smoothing to input angles if needed. + +# Algorithm +1. Copies theta_angles and delta_angles directly to wing.theta_dist and wing.delta_dist +2. Calls deform! to update wing geometry and propagate to refined sections + +# Errors +- Throws `ArgumentError` if wing doesn't support deformation but angles are provided +- Throws `ArgumentError` if angle vectors don't match n_unrefined_sections + +# Returns +- `nothing` (modifies wing in-place) +""" +function unrefined_deform!(wing::Wing, theta_angles=nothing, delta_angles=nothing; + smooth=false, smooth_window=nothing) + # Check if deformation is supported + can_deform = !isempty(wing.non_deformed_sections) + + # If no deformation requested, just return + isnothing(theta_angles) && isnothing(delta_angles) && return nothing + + # If deformation requested but not supported, throw error + if !can_deform + throw(ArgumentError("This Wing does not support deformation. Only OBJ-based wings created with ObjWing() can be deformed.")) + end + + # Validate inputs + !isnothing(theta_angles) && length(theta_angles) != wing.n_unrefined_sections && + throw(ArgumentError("theta_angles must have length n_unrefined_sections = $(wing.n_unrefined_sections), got $(length(theta_angles))")) + !isnothing(delta_angles) && length(delta_angles) != wing.n_unrefined_sections && + throw(ArgumentError("delta_angles must have length n_unrefined_sections = $(wing.n_unrefined_sections), got $(length(delta_angles))")) + + # Map unrefined sections → panels → sections (no smoothing yet) + if !isnothing(theta_angles) + map_unrefined_to_sections!(wing.theta_dist, theta_angles, wing.refined_panel_mapping, wing.n_panels) + end + if !isnothing(delta_angles) + map_unrefined_to_sections!(wing.delta_dist, delta_angles, wing.refined_panel_mapping, wing.n_panels) + end + + # Apply deformation with optional smoothing + deform!(wing, smooth=smooth, smooth_window=smooth_window) + return nothing +end + +""" + map_unrefined_to_sections!(panel_dist, unrefined_angles, panel_mapping, n_panels) + +Map angles from unrefined sections to panels. +Steps: unrefined[1:n_unrefined] → panels[1:n_panels] + +# Arguments +- `panel_dist::Vector{Float64}`: Output panel angles (length n_panels) +- `unrefined_angles::Vector{Float64}`: Input unrefined section angles +- `panel_mapping::Vector{Int16}`: Maps panel index to unrefined section index +- `n_panels::Int`: Number of panels +""" +function map_unrefined_to_sections!(panel_dist, + unrefined_angles, + panel_mapping, + n_panels) + # Map unrefined sections to panels + for i in 1:n_panels + unrefined_idx = panel_mapping[i] + panel_dist[i] = unrefined_angles[unrefined_idx] + end + + return nothing +end + +""" + smooth_distribution!(dist, window_size) + +Apply moving average smoothing to a distribution in-place. +Uses a centered window of size `window_size` (must be odd). + +# Arguments +- `dist::Vector{Float64}`: Distribution to smooth (modified in-place) +- `window_size::Int`: Size of smoothing window (must be odd) +""" +function smooth_distribution!(dist::Vector{Float64}, window_size::Int) + n = length(dist) + window_size <= 1 && return nothing + n <= window_size && return nothing + + # Create temporary copy + dist_copy = copy(dist) + half_window = div(window_size, 2) + + # Apply moving average + for i in 1:n + start_idx = max(1, i - half_window) + end_idx = min(n, i + half_window) + dist[i] = sum(dist_copy[start_idx:end_idx]) / (end_idx - start_idx + 1) + end + + return nothing +end + +""" + deform!(wing::Wing, theta_dist::AbstractVector, delta_dist::AbstractVector; + smooth=false, smooth_window=nothing) + +Deform wing by applying theta and delta distributions at the panel level. + +# Arguments +- `wing::Wing`: Wing to deform (must support deformation) +- `theta_dist::AbstractVector`: Twist angles for each panel (length = n_panels) +- `delta_dist::AbstractVector`: TE deflections for each panel (length = n_panels) +- `smooth::Bool`: Whether to apply smoothing (default: false) +- `smooth_window::Union{Nothing, Int}`: Smoothing window size (default: auto-calculated) + +# Effects +Updates wing.refined_sections with deformed geometry based on wing.non_deformed_sections +""" +function deform!(wing::Wing, theta_dist::AbstractVector, delta_dist::AbstractVector; + smooth=false, smooth_window=nothing) + !isempty(wing.non_deformed_sections) || throw(ArgumentError("Wing does not support deformation")) + + expected_len = wing.n_panels + !(length(theta_dist) == expected_len) && throw(ArgumentError("theta_dist must have length $(expected_len), got $(length(theta_dist))")) + !(length(delta_dist) == expected_len) && throw(ArgumentError("delta_dist must have length $(expected_len), got $(length(delta_dist))")) + + wing.theta_dist .= theta_dist + wing.delta_dist .= delta_dist + + deform!(wing, smooth=smooth, smooth_window=smooth_window) +end + +""" + deform!(wing::Wing; smooth=false, smooth_window=nothing) + +Apply stored theta_dist and delta_dist to deform the wing geometry. +Converts panel angles (n_panels) to section angles (n_panels+1) by averaging adjacent panels. + +# Arguments +- `wing::Wing`: Wing to deform (must have non_deformed_sections) +- `smooth::Bool`: Whether to apply smoothing to theta_dist and delta_dist (default: false) +- `smooth_window::Union{Nothing, Int}`: Smoothing window size (default: auto-calculated) + +# Effects +Updates wing.refined_sections based on wing.non_deformed_sections and stored distributions +""" +function deform!(wing::Wing; smooth=false, smooth_window=nothing) + !isempty(wing.non_deformed_sections) || return nothing + + # Apply smoothing if requested + if smooth + # Default window size based on refinement ratio + if isnothing(smooth_window) + smooth_window = max(3, round(Int, wing.n_panels / wing.n_unrefined_sections)) + end + + # Ensure window is odd for symmetric averaging + if smooth_window % 2 == 0 + smooth_window += 1 + end + + # Apply moving average to theta_dist and delta_dist (panel-level) + smooth_distribution!(wing.theta_dist, smooth_window) + smooth_distribution!(wing.delta_dist, smooth_window) + end + + local_y = zeros(MVec3) + chord = zeros(MVec3) + normal = zeros(MVec3) + + # Process all refined sections (n_panels + 1) + # Convert panel angles to section angles by averaging + for i in 1:(wing.n_panels + 1) + # Determine theta for this section by averaging adjacent panels + if i == 1 + # First section: use panel 1 angle + theta = wing.theta_dist[1] + elseif i == wing.n_panels + 1 + # Last section: use last panel angle + theta = wing.theta_dist[wing.n_panels] + else + # Middle sections: average of panels (i-1) and i + theta = (wing.theta_dist[i-1] + wing.theta_dist[i]) / 2.0 + end + + section = wing.non_deformed_sections[i] + + # Compute local coordinate system + if i < wing.n_panels + 1 + section2 = wing.non_deformed_sections[i+1] + local_y .= normalize(section.LE_point - section2.LE_point) + else + # For last section, use same local_y as previous + section_prev = wing.non_deformed_sections[i-1] + local_y .= normalize(section_prev.LE_point - section.LE_point) + end + + chord .= section.TE_point .- section.LE_point + normal .= chord × local_y + @. wing.refined_sections[i].TE_point = section.LE_point + + cos(theta) * chord - sin(theta) * normal + end + return nothing +end + """ remove_vector_nans(aero_data) @@ -295,14 +580,15 @@ Add a new section to the wing. - `aero_model`::AeroModel: [AeroModel](@ref) - `aero_data`::AeroData: See [AeroData](@ref) """ -function add_section!(wing::Wing, LE_point, +function add_section!(wing::Wing, LE_point, TE_point, aero_model::AeroModel, aero_data::AeroData=nothing) if aero_model == POLAR_VECTORS && wing.remove_nan aero_data = remove_vector_nans(aero_data) elseif aero_model == POLAR_MATRICES && wing.remove_nan interpolate_matrix_nans!.(aero_data[3:5]) end - push!(wing.sections, Section(LE_point, TE_point, aero_model, aero_data)) + push!(wing.unrefined_sections, Section(LE_point, TE_point, aero_model, aero_data)) + wing.n_unrefined_sections = Int16(length(wing.unrefined_sections)) return nothing end @@ -326,19 +612,104 @@ end """ - refine_aerodynamic_mesh!(wing::AbstractWing) + update_non_deformed_sections!(wing::AbstractWing) -Refine the aerodynamic mesh of the wing based on spanwise panel distribution. +Create non_deformed_sections to match refined_sections. +This enables deformation support for all wings (YAML and OBJ). +Should be called after refined_sections are populated. +Once populated, non_deformed_sections serves as the undeformed reference geometry. +""" +function update_non_deformed_sections!(wing::AbstractWing) + n_sections = wing.n_panels + 1 -Returns: - Vector{Section}: List of refined sections + # Populate or update non_deformed_sections + if isempty(wing.non_deformed_sections) + # Initial setup + wing.non_deformed_sections = [Section() for _ in 1:n_sections] + for i in 1:n_sections + reinit!(wing.non_deformed_sections[i], wing.refined_sections[i]) + end + elseif length(wing.non_deformed_sections) != n_sections + # Size mismatch - error + throw(ArgumentError( + "non_deformed_sections has incorrect size. " * + "Expected $(n_sections) sections (n_panels+1), got $(length(wing.non_deformed_sections)). " * + "This indicates an inconsistent wing state." + )) + else + # Correct size - update all sections + for i in 1:n_sections + reinit!(wing.non_deformed_sections[i], wing.refined_sections[i]) + end + end + return nothing +end + +""" + refine!(wing::AbstractWing; recompute_mapping=true, sort_sections=true) + +Refine the wing aerodynamic mesh from unrefined sections to refined sections. + +This function interpolates the wing geometry from a coarse set of unrefined sections +to a fine mesh of refined sections (n_panels+1 sections) based on the wing's +spanwise_distribution setting. It also populates non_deformed_sections which +enables deformation support via `unrefined_deform!`. + +# Required Workflow +Must be called after wing construction and before creating `BodyAerodynamics`: +```julia +wing = Wing("wing.yaml"; n_panels=40) # or ObjWing(...) or manual Wing +refine!(wing) # Refine mesh +body_aero = BodyAerodynamics([wing]) # Create aerodynamics +``` + +# Distribution Methods +- `LINEAR`: Linear interpolation between sections +- `COSINE`: Cosine spacing (more panels near tips) +- `COSINE_VAN_GARREL`: van Garrel cosine distribution +- `SPLIT_PROVIDED`: Split each unrefined section into sub-panels +- `UNCHANGED`: 1:1 copy when n_unrefined_sections == n_panels+1 + +# Keyword Arguments +- `recompute_mapping::Bool=true`: Recompute the mapping from refined panels to unrefined sections +- `sort_sections::Bool=true`: Sort sections by spanwise position (disable for structural ordering) + +# Effects +1. Populates `wing.refined_sections` (n_panels+1 sections) +2. Populates `wing.non_deformed_sections` (copy of refined_sections for deformation reference) +3. Computes `wing.refined_panel_mapping` (panel → unrefined section mapping) +4. Resizes `wing.theta_dist` and `wing.delta_dist` to n_panels + +# Example +```julia +# YAML wing +wing = Wing("wing.yaml"; n_panels=40) +refine!(wing) +body_aero = BodyAerodynamics([wing]) + +# After refinement, deformation is supported +unrefined_deform!(wing, theta_angles, delta_angles) +``` """ -function refine_aerodynamic_mesh!(wing::AbstractWing) - sort!(wing.sections, by=s -> s.LE_point[2], rev=true) +function refine!(wing::AbstractWing; recompute_mapping=true, sort_sections=true) + # Validate unrefined_sections exist + if isempty(wing.unrefined_sections) + throw(ArgumentError( + "Cannot refine mesh: wing has no unrefined_sections. " * + "Add sections using add_section! or check wing construction." + )) + end + + # Only sort sections if requested (skip for REFINE wings with fixed structural order) + sort_sections && sort!(wing.unrefined_sections, by=s -> s.LE_point[2], rev=true) n_sections = wing.n_panels + 1 + if length(wing.refined_sections) == 0 - if wing.spanwise_distribution == UNCHANGED || length(wing.sections) == n_sections - wing.refined_sections = wing.sections + if wing.spanwise_distribution == UNCHANGED || + length(wing.unrefined_sections) == n_sections + wing.refined_sections = wing.unrefined_sections + recompute_mapping && compute_refined_panel_mapping!(wing) + update_non_deformed_sections!(wing) return nothing else wing.refined_sections = Section[Section() for _ in 1:wing.n_panels+1] @@ -346,13 +717,13 @@ function refine_aerodynamic_mesh!(wing::AbstractWing) end # Extract geometry data - n_current = length(wing.sections) + n_current = length(wing.unrefined_sections) LE = zeros(Float64, n_current, 3) TE = zeros(Float64, n_current, 3) - aero_model = Vector{typeof(wing.sections[1].aero_model)}() - aero_data = Vector{typeof(wing.sections[1].aero_data)}() + aero_model = Vector{typeof(wing.unrefined_sections[1].aero_model)}() + aero_data = Vector{typeof(wing.unrefined_sections[1].aero_data)}() - for (i, section) in enumerate(wing.sections) + for (i, section) in enumerate(wing.unrefined_sections) LE[i,:] = section.LE_point TE[i,:] = section.TE_point push!(aero_model, section.aero_model) @@ -365,27 +736,31 @@ function refine_aerodynamic_mesh!(wing::AbstractWing) end # Handle special cases - if wing.spanwise_distribution == UNCHANGED || length(wing.sections) == n_sections - for i in eachindex(wing.sections) - reinit!(wing.refined_sections[i], wing.sections[i]) + if wing.spanwise_distribution == UNCHANGED || length(wing.unrefined_sections) == n_sections + for i in eachindex(wing.unrefined_sections) + reinit!(wing.refined_sections[i], wing.unrefined_sections[i]) end + recompute_mapping && compute_refined_panel_mapping!(wing) + update_non_deformed_sections!(wing) return nothing end - @debug "Refining aerodynamic mesh from $(length(wing.sections)) sections to $n_sections sections." - + @debug "Refining aerodynamic mesh from $(length(wing.unrefined_sections)) sections to $n_sections sections." + # Handle two-section case if n_sections == 2 reinit!(wing.refined_sections[1], LE[1,:], TE[1,:], aero_model[1], aero_data[1]) reinit!(wing.refined_sections[2], LE[end,:], TE[end,:], aero_model[end], aero_data[end]) + recompute_mapping && compute_refined_panel_mapping!(wing) + update_non_deformed_sections!(wing) return nothing end - + # Handle different distribution types if wing.spanwise_distribution == SPLIT_PROVIDED - return refine_mesh_by_splitting_provided_sections!(wing) + refine_mesh_by_splitting_provided_sections!(wing) elseif wing.spanwise_distribution in (LINEAR, COSINE, COSINE_VAN_GARREL) - return refine_mesh_for_linear_cosine_distribution!( + refine_mesh_for_linear_cosine_distribution!( wing, 1, wing.spanwise_distribution, @@ -398,6 +773,86 @@ function refine_aerodynamic_mesh!(wing::AbstractWing) else throw(ArgumentError("Unsupported spanwise panel distribution: $(wing.spanwise_distribution)")) end + + # Compute panel mapping by finding closest unrefined section for each refined panel + recompute_mapping && compute_refined_panel_mapping!(wing) + + # Update n_unrefined_sections based on actual sections + wing.n_unrefined_sections = Int16(length(wing.unrefined_sections)) + + # Resize theta_dist and delta_dist to match n_panels + target_size = wing.n_panels + if length(wing.theta_dist) != target_size + resize!(wing.theta_dist, target_size) + fill!(wing.theta_dist, 0.0) + end + if length(wing.delta_dist) != target_size + resize!(wing.delta_dist, target_size) + fill!(wing.delta_dist, 0.0) + end + + # Create/update non_deformed_sections to match refined_sections + update_non_deformed_sections!(wing) + + return nothing +end + + +""" + compute_refined_panel_mapping!(wing::AbstractWing) + +Compute the mapping from refined panels to unrefined sections by finding +the closest unrefined section for each refined panel (based on section center distance). +Maps each refined panel index to its corresponding unrefined section index +(1 to n_unrefined_sections). +This is non-allocating and works after refinement is complete. +""" +function compute_refined_panel_mapping!(wing::AbstractWing) + n_unrefined_sections = length(wing.unrefined_sections) + n_refined_panels = wing.n_panels + + # Handle case where no refinement occurred + if n_unrefined_sections == n_refined_panels + 1 + wing.refined_panel_mapping = Int16[i for i in 1:n_refined_panels] + return nothing + end + + # Ensure mapping array is allocated + if length(wing.refined_panel_mapping) != n_refined_panels + wing.refined_panel_mapping = zeros(Int16, n_refined_panels) + end + + # Compute centers of unrefined sections + unrefined_centers = Vector{MVec3}(undef, n_unrefined_sections) + for i in 1:n_unrefined_sections + le_point = wing.unrefined_sections[i].LE_point + te_point = wing.unrefined_sections[i].TE_point + unrefined_centers[i] = MVec3((le_point + te_point) / 2) + end + + # For each refined panel, find closest unrefined section + for refined_panel_idx in 1:n_refined_panels + le_mid = (wing.refined_sections[refined_panel_idx].LE_point + + wing.refined_sections[refined_panel_idx+1].LE_point) / 2 + te_mid = (wing.refined_sections[refined_panel_idx].TE_point + + wing.refined_sections[refined_panel_idx+1].TE_point) / 2 + refined_center = MVec3((le_mid + te_mid) / 2) + + # Find closest unrefined section + min_dist = Inf + closest_idx = 1 + for unrefined_section_idx in 1:n_unrefined_sections + dist = norm(refined_center - unrefined_centers[unrefined_section_idx]) + if dist < min_dist + min_dist = dist + closest_idx = unrefined_section_idx + end + end + + wing.refined_panel_mapping[refined_panel_idx] = Int16(closest_idx) + end + + return nothing end @@ -549,7 +1004,7 @@ function refine_mesh_for_linear_cosine_distribution!( target_length = target_lengths[i] # Find segment index - section_index = searchsortedlast(qc_cum_length, target_length) + section_index = searchsortedlast(qc_cum_length, target_length) section_index = clamp(section_index, 1, length(qc_cum_length)-1) # 4. Calculate weights @@ -560,7 +1015,7 @@ function refine_mesh_for_linear_cosine_distribution!( right_weight = t # 5. Calculate quarter chord point - new_quarter_chord[i,:] = quarter_chord[section_index,:] + + new_quarter_chord[i,:] = quarter_chord[section_index,:] + t .* (quarter_chord[section_index+1,:] - quarter_chord[section_index,:]) # 6. Calculate chord vectors @@ -673,7 +1128,7 @@ Returns: Vector{Section}: Refined sections """ function refine_mesh_by_splitting_provided_sections!(wing::AbstractWing) - n_sections_provided = length(wing.sections) + n_sections_provided = length(wing.unrefined_sections) n_panels_provided = n_sections_provided - 1 n_panels_desired = wing.n_panels @@ -681,7 +1136,7 @@ function refine_mesh_by_splitting_provided_sections!(wing::AbstractWing) # Check if refinement is needed if n_panels_provided == n_panels_desired - for (refined_section, section) in zip(wing.refined_sections, wing.sections) + for (refined_section, section) in zip(wing.refined_sections, wing.unrefined_sections) reinit!(refined_section, section) end return nothing @@ -701,21 +1156,21 @@ function refine_mesh_by_splitting_provided_sections!(wing::AbstractWing) new_sections_per_pair, remaining = divrem(n_new_sections, n_section_pairs) # Extract geometry data - LE = [section.LE_point for section in wing.sections] - TE = [section.TE_point for section in wing.sections] - aero_model = [section.aero_model for section in wing.sections] - aero_data = [section.aero_data for section in wing.sections] + LE = [section.LE_point for section in wing.unrefined_sections] + TE = [section.TE_point for section in wing.unrefined_sections] + aero_model = [section.aero_model for section in wing.unrefined_sections] + aero_data = [section.aero_data for section in wing.unrefined_sections] # Process each section pair idx = 1 for left_section_index in 1:n_section_pairs # Add left section of pair - reinit!(wing.refined_sections[idx], wing.sections[left_section_index]) + reinit!(wing.refined_sections[idx], wing.unrefined_sections[left_section_index]) idx += 1 - + # Calculate new sections for this pair num_new_sections = new_sections_per_pair + (left_section_index <= remaining ? 1 : 0) - + if num_new_sections > 0 # Prepare pair data LE_pair = hcat(LE[left_section_index], LE[left_section_index + 1])' @@ -728,7 +1183,7 @@ function refine_mesh_by_splitting_provided_sections!(wing::AbstractWing) aero_data[left_section_index], aero_data[left_section_index + 1] ] - + # Generate sections for this pair idx = refine_mesh_for_linear_cosine_distribution!( wing, @@ -745,7 +1200,7 @@ function refine_mesh_by_splitting_provided_sections!(wing::AbstractWing) end # Add final section - reinit!(wing.refined_sections[idx], wing.sections[end]) + reinit!(wing.refined_sections[idx], wing.unrefined_sections[end]) idx += 1 # Validate result @@ -771,7 +1226,7 @@ function calculate_span(wing::AbstractWing) # Get all points all_points = reduce(vcat, [[section.LE_point, section.TE_point] - for section in wing.sections]) + for section in wing.unrefined_sections]) # Project points and calculate span projections = [dot(point, vector_axis) for point in all_points] @@ -804,12 +1259,12 @@ function calculate_projected_area(wing::AbstractWing, # Calculate area by summing trapezoid areas projected_area = 0.0 - for i in 1:(length(wing.sections)-1) + for i in 1:(length(wing.unrefined_sections)-1) # Get section points - LE_current = wing.sections[i].LE_point - TE_current = wing.sections[i].TE_point - LE_next = wing.sections[i+1].LE_point - TE_next = wing.sections[i+1].TE_point + LE_current = wing.unrefined_sections[i].LE_point + TE_current = wing.unrefined_sections[i].TE_point + LE_next = wing.unrefined_sections[i+1].LE_point + TE_next = wing.unrefined_sections[i+1].TE_point # Project points project_onto_plane!(LE_current_proj, LE_current, z_plane_vector) @@ -837,4 +1292,4 @@ function Base.getproperty(w::AbstractWing, s::Symbol) else return getfield(w, s) end -end \ No newline at end of file +end diff --git a/src/yaml_geometry.jl b/src/yaml_geometry.jl index 1abf3118..e6c39514 100644 --- a/src/yaml_geometry.jl +++ b/src/yaml_geometry.jl @@ -148,7 +148,7 @@ function load_polar_data(csv_file_path::String) end """ - Wing(geometry_file::String; n_panels=20, n_groups=1, spanwise_distribution=LINEAR, + Wing(geometry_file::String; n_panels=20, spanwise_distribution=LINEAR, spanwise_direction=[0.0, 1.0, 0.0], remove_nan=true, prn=false) Constructs a `Wing` object from a YAML geometry file. @@ -158,7 +158,6 @@ Constructs a `Wing` object from a YAML geometry file. # Keyword Arguments - `n_panels::Int`: Number of spanwise panels (default: 20). -- `n_groups::Int`: Number of grouped sections across the span (default: 1). Must divide `n_panels`. - `spanwise_distribution`: Spanwise panel distribution type (default: `LINEAR`). - `spanwise_direction::Vector{Float64}`: Direction of the spanwise axis (default: `[0.0, 1.0, 0.0]`). Must be the global Y axis. - `remove_nan::Bool`: Remove NaN values from the geometry (default: `true`). @@ -171,28 +170,27 @@ Constructs a `Wing` object from a YAML geometry file. This function reads a YAML configuration file to define the geometry and airfoil data for a multi-section wing. Each section and corresponding airfoil is parsed from the YAML file, polar data is loaded, and each section is added to the wing. The geometry logic currently assumes the spanwise direction is `[0.0, 1.0, 0.0]` (aligned with the global Y axis). +The number of unrefined sections is automatically inferred from the sections in the geometry file. # Errors -- Throws an `ArgumentError` if `n_panels` is not divisible by `n_groups`. - Throws an `ArgumentError` if `spanwise_direction` is not `[0.0, 1.0, 0.0]`. # Example ```julia -wing = Wing("wing_geometry.yaml"; n_panels=30, n_groups=2, prn=true) +wing = Wing("wing_geometry.yaml"; n_panels=30, prn=true) ``` """ function Wing( geometry_file::String; n_panels=20, - n_groups=1, spanwise_distribution=LINEAR, spanwise_direction=[0.0, 1.0, 0.0], remove_nan=true, prn=false ) - !(n_panels % n_groups == 0) && throw(ArgumentError("Number of panels should be divisible by number of groups")) + !isapprox(spanwise_direction, [0.0, 1.0, 0.0]) && throw(ArgumentError("Spanwise direction has to be [0.0, 1.0, 0.0], not $spanwise_direction")) - + prn && @info "Reading YAML wing configuration from $geometry_file" # Load YAML file following Uwe's suggestion @@ -236,10 +234,10 @@ function Wing( end # Create Wing using the standard constructor - wing = Wing(n_panels; - n_groups=n_groups, + # n_unrefined_sections will be set automatically after sections are added + wing = Wing(n_panels; spanwise_distribution=spanwise_distribution, - spanwise_direction=MVec3(spanwise_direction), + spanwise_direction=MVec3(spanwise_direction), remove_nan=remove_nan ) @@ -248,7 +246,7 @@ function Wing( # Get coordinates directly from struct fields le_coord = [section.LE_x, section.LE_y, section.LE_z] te_coord = [section.TE_x, section.TE_y, section.TE_z] - + # Load polar data and create section csv_file_path = get(airfoil_csv_map, section.airfoil_id, "") if !isempty(csv_file_path) && !isabspath(csv_file_path) @@ -258,15 +256,13 @@ function Wing( csv_file_path = joinpath(dirname(geometry_file), csv_file_path) end aero_data, aero_model = load_polar_data(csv_file_path) - + prn && println("Section airfoil_id $(section.airfoil_id): Using $aero_model model") - + add_section!(wing, le_coord, te_coord, aero_model, aero_data) end - - # Initialize the wing after adding all sections - reinit!(wing) - + + refine!(wing) return wing end @@ -275,9 +271,13 @@ end Create a wing model from VSM settings configuration. -This constructor is a convenience wrapper that extracts wing configuration -from VSMSettings and creates a Wing using the YAML geometry file path and -parameters specified in the settings. +This constructor is a convenience wrapper that extracts wing configuration +from VSMSettings and creates a Wing using either: +- YAML geometry file (geometry_file field), or +- OBJ + DAT files (obj_file and dat_file fields) + +The constructor automatically determines which path to use based on which +fields are populated in the settings. # Arguments - `settings`: VSMSettings object containing wing configuration @@ -287,15 +287,62 @@ A fully initialized `Wing` instance ready for aerodynamic simulation. # Example ```julia -# Load settings and create wing in one step +# Using YAML geometry settings = VSMSettings("path/to/vsm_settings.yaml") wing = Wing(settings) + +# Settings can specify either: +# - geometry_file: "path/to/wing.yaml" # YAML-based +# - obj_file + dat_file # OBJ-based ``` """ function Wing(settings::VSMSettings) - Wing(settings.wings[1].geometry_file; - n_panels=settings.wings[1].n_panels, - n_groups=settings.wings[1].n_groups, - spanwise_distribution=settings.wings[1].spanwise_panel_distribution - ) + wing_settings = settings.wings[1] + + # Check which geometry format to use + has_yaml = !isempty(wing_settings.geometry_file) + has_obj = !isempty(wing_settings.obj_file) + has_dat = !isempty(wing_settings.dat_file) + + if has_yaml && (has_obj || has_dat) + throw(ArgumentError( + "Cannot specify both geometry_file and obj_file/dat_file" + )) + end + + if has_obj && !has_dat + throw(ArgumentError( + "obj_file requires dat_file to be specified" + )) + end + + if has_dat && !has_obj + throw(ArgumentError( + "dat_file requires obj_file to be specified" + )) + end + + if has_yaml + # Use YAML geometry constructor + Wing(wing_settings.geometry_file; + n_panels=wing_settings.n_panels, + spanwise_distribution=wing_settings.spanwise_panel_distribution, + remove_nan=wing_settings.remove_nan + ) + elseif has_obj && has_dat + # Use ObjWing constructor + ObjWing( + wing_settings.obj_file, + wing_settings.dat_file; + n_panels=wing_settings.n_panels, + spanwise_distribution=wing_settings.spanwise_panel_distribution, + spanwise_direction=wing_settings.spanwise_direction, + remove_nan=wing_settings.remove_nan + ) + else + throw(ArgumentError( + "WingSettings must specify either geometry_file or " * + "both obj_file and dat_file" + )) + end end diff --git a/test/Aqua.jl b/test/Aqua.jl index e16c0cc9..7a885062 100644 --- a/test/Aqua.jl +++ b/test/Aqua.jl @@ -1,8 +1,3 @@ -using Pkg -if ! ("Aqua" ∈ keys(Pkg.project().dependencies)) - using TestEnv; TestEnv.activate() -end - using Aqua, VortexStepMethod, Test @testset "Aqua.jl" begin Aqua.test_all( diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 00000000..fca78da6 --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,33 @@ +[deps] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" + +[compat] +Aqua = "0.8" +BenchmarkTools = "1" +CSV = "0.10" +DataFrames = "1.7" +Documenter = "1.8" +GLMakie = "0" +Interpolations = "0.15, 0.16" +LinearAlgebra = "1" +Logging = "1" +Random = "1.10.0" +Serialization = "1" +StaticArrays = "1" +Statistics = "1" +Test = "1" +YAML = "0.4.13" diff --git a/test/TestSupport.jl b/test/TestSupport.jl deleted file mode 100644 index 4b428502..00000000 --- a/test/TestSupport.jl +++ /dev/null @@ -1,5 +0,0 @@ -module TestSupport -export suppress_warnings, test_data_path, create_temp_wing_settings, - get_standard_wing_file, get_complete_settings_file -include("test_data_utils.jl") -end \ No newline at end of file diff --git a/test/bench.jl b/test/bench.jl index 5acfb95f..42c256f4 100644 --- a/test/bench.jl +++ b/test/bench.jl @@ -1,8 +1,3 @@ -using Pkg -if !("BenchmarkTools" ∈ keys(Pkg.project().dependencies)) - using TestEnv - TestEnv.activate() -end using BenchmarkTools using StaticArrays using VortexStepMethod @@ -19,6 +14,9 @@ using VortexStepMethod: calculate_AIC_matrices!, gamma_loop!, calculate_results, using Test using LinearAlgebra +# Check Julia version for known allocation issues +const IS_JULIA_1_12_OR_NEWER = VERSION >= v"1.12" + @testset "Function Allocation Tests" begin # Define wing parameters n_panels = 20 # Number of panels @@ -53,10 +51,12 @@ using LinearAlgebra [chord, -span/2, 0.0], # Right tip TE INVISCID) + refine!(wing) body_aero = BodyAerodynamics([wing]) + refine!(unchanged_wing) unchanged_body_aero = BodyAerodynamics([unchanged_wing]) reinit!(unchanged_body_aero) - + @testset "Re-initialization" begin result = @benchmark reinit!($unchanged_body_aero; init_aero=false) samples=1 evals=1 @info "Re-initializing Allocations: $(result.allocs) \t Memory: $(result.memory)" @@ -90,7 +90,11 @@ using LinearAlgebra for frac in core_radius_fractions @testset "Model $model Core Radius Fraction $frac" begin result = @benchmark calculate_AIC_matrices!($body_aero, $model, $frac, $va_norm_array, $va_unit_array) samples=1 evals=1 - @test result.allocs ≤ 30 + if IS_JULIA_1_12_OR_NEWER + @test_broken result.allocs ≤ 30 + else + @test result.allocs ≤ 30 + end @info "Model: $(model) \t Core radius fraction: $(frac) \t Allocations: $(result.allocs) \t Memory: $(result.memory)" end end @@ -134,16 +138,18 @@ using LinearAlgebra [chord, -span/2, 0.0], # Right tip TE aero_model, aero_data) - body_aero = BodyAerodynamics([wing]) + refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) solver = Solver(body_aero; aerodynamic_model_type=model ) - solver.sol._va_array .= va_array - solver.sol._chord_array .= chord_array - solver.sol._x_airf_array .= x_airf_array - solver.sol._y_airf_array .= y_airf_array - solver.sol._z_airf_array .= z_airf_array + solver.sol._va_dist .= va_array + solver.sol._chord_dist .= chord_array + solver.sol._x_airf_dist .= x_airf_array + solver.sol._y_airf_dist .= y_airf_array + solver.sol._z_airf_dist .= z_airf_array result = @benchmark gamma_loop!( $solver, $body_aero, @@ -208,17 +214,33 @@ using LinearAlgebra @testset "Allocation Tests for solve() and solve!()" begin result = @benchmark solve_base!($solver, $body_aero, nothing) samples=1 evals=1 # 51 allocations - @test result.allocs <= 55 + if IS_JULIA_1_12_OR_NEWER + @test_broken result.allocs <= 55 + else + @test result.allocs <= 55 + end # time Python: 32.0 ms Ryzen 7950x # time Julia: 0.45 ms Ryzen 7950x result = @benchmark sol = solve!($solver, $body_aero, nothing) samples=1 evals=1 # 85 allocations - @test result.allocs <= 89 + if IS_JULIA_1_12_OR_NEWER + @test_broken result.allocs <= 89 + else + @test result.allocs <= 89 + end # Step 5: Solve using both methods result = @benchmark solve_base!($nonlin_solver, $body_aero, nothing) samples=1 evals=1 # 51 allocations - @test result.allocs <= 55 + if IS_JULIA_1_12_OR_NEWER + @test_broken result.allocs <= 55 + else + @test result.allocs <= 55 + end result = @benchmark sol = solve!($nonlin_solver, $body_aero, nothing) samples=1 evals=1 # 85 allocations - @test result.allocs <= 89 + if IS_JULIA_1_12_OR_NEWER + @test_broken result.allocs <= 89 + else + @test result.allocs <= 89 + end end end diff --git a/test/bench_solve.jl b/test/bench_solve.jl index 1abe47d7..1f3b934f 100644 --- a/test/bench_solve.jl +++ b/test/bench_solve.jl @@ -6,12 +6,6 @@ using VortexStepMethod using BenchmarkTools using Test -using Pkg - -if !("CSV" ∈ keys(Pkg.project().dependencies)) - using TestEnv - TestEnv.activate() -end # Step 1: Define wing parameters n_panels = 20 # Number of panels @@ -36,7 +30,9 @@ add_section!(wing, INVISCID) # Step 3: Initialize aerodynamics -wa = BodyAerodynamics([wing]) +wa = refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) # Set inflow conditions vel_app = [cos(alpha), 0.0, sin(alpha)] .* v_a diff --git a/test/body_aerodynamics/complete_settings.yaml b/test/body_aerodynamics/complete_settings.yaml index a40ff608..214f7c30 100644 --- a/test/body_aerodynamics/complete_settings.yaml +++ b/test/body_aerodynamics/complete_settings.yaml @@ -2,7 +2,6 @@ wings: - name: "body_aero_test_wing" geometry_file: "test/body_aerodynamics/test_wing.yaml" n_panels: 4 - n_groups: 2 spanwise_panel_distribution: COSINE spanwise_direction: [0.0, 1.0, 0.0] remove_nan: true diff --git a/test/body_aerodynamics/test_body_aerodynamics.jl b/test/body_aerodynamics/test_body_aerodynamics.jl index c022102f..c89415aa 100644 --- a/test/body_aerodynamics/test_body_aerodynamics.jl +++ b/test/body_aerodynamics/test_body_aerodynamics.jl @@ -37,6 +37,8 @@ include("../utils.jl") ) end + refine!(wing) + refine!(wing) body_aero = BodyAerodynamics([wing]) set_va!(body_aero, v_a) @@ -120,18 +122,19 @@ end add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], INVISCID) add_section!(wing, [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], INVISCID) add_section!(wing, [0.0, 2.0, 0.0], [1.0, 2.0, 0.0], INVISCID) - + refine!(wing) + # Test non-zero origin translation origin = MVec3(1.0, 2.0, 3.0) body_aero = BodyAerodynamics([wing]; kite_body_origin=origin) # Check if sections are correctly translated - @test wing.sections[3].LE_point ≈ [-1.0, -2.0, -3.0] - @test wing.sections[3].TE_point ≈ [0.0, -2.0, -3.0] - @test wing.sections[2].LE_point ≈ [-1.0, -1.0, -3.0] - @test wing.sections[2].TE_point ≈ [0.0, -1.0, -3.0] - @test wing.sections[1].LE_point ≈ [-1.0, 0.0, -3.0] - @test wing.sections[1].TE_point ≈ [0.0, 0.0, -3.0] + @test wing.unrefined_sections[3].LE_point ≈ [-1.0, -2.0, -3.0] + @test wing.unrefined_sections[3].TE_point ≈ [0.0, -2.0, -3.0] + @test wing.unrefined_sections[2].LE_point ≈ [-1.0, -1.0, -3.0] + @test wing.unrefined_sections[2].TE_point ≈ [0.0, -1.0, -3.0] + @test wing.unrefined_sections[1].LE_point ≈ [-1.0, 0.0, -3.0] + @test wing.unrefined_sections[1].TE_point ≈ [0.0, 0.0, -3.0] end function create_geometry(; model=VSM, wing_type=:rectangular, plotting=false, N=40) @@ -174,7 +177,9 @@ end INVISCID ) end - body_aero = BodyAerodynamics([wing]) + refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) set_va!(body_aero, v_a) return body_aero, coord, v_a, model @@ -304,6 +309,8 @@ end ) end + refine!(wing) + refine!(wing) body_aero = BodyAerodynamics([wing]) set_va!(body_aero, v_a) @@ -351,9 +358,6 @@ end @test loop_sol.solver_status == FEASIBLE - @test sum(loop_sol.moment_dist) ≈ sum(loop_sol.group_moment_dist) - @test sum(nonlin_sol.moment_dist) ≈ sum(nonlin_sol.group_moment_dist) - end # Calculate forces using uncorrected alpha @@ -419,7 +423,9 @@ end try settings = VSMSettings(settings_file) wing = Wing(settings) - body_aero = BodyAerodynamics([wing]) + body_aero = refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) set_va!(body_aero, settings) diff --git a/test/body_aerodynamics/test_results.jl b/test/body_aerodynamics/test_results.jl index 2b47dc5b..5378bedf 100644 --- a/test/body_aerodynamics/test_results.jl +++ b/test/body_aerodynamics/test_results.jl @@ -31,7 +31,7 @@ if !@isdefined ram_wing_results error("Required data files not found: $body_src or $foil_src") end - ram_wing = RamAirWing(body_path, foil_path; alpha_range=deg2rad.(-1:1), delta_range=deg2rad.(-1:1)) + ram_wing = ObjWing(body_path, foil_path; alpha_range=deg2rad.(-1:1), delta_range=deg2rad.(-1:1), n_unrefined_sections=4) end @testset "Nonlinear vs Linear - Comprehensive Input Testing" begin @@ -48,7 +48,7 @@ end domega_magnitudes = [deg2rad(0.1), deg2rad(0.5), deg2rad(1.0)] # Angular rate perturbations (rad/s) # Create body aerodynamics and solver - VortexStepMethod.group_deform!(ram_wing, theta, delta; smooth=false) + VortexStepMethod.unrefined_deform!(ram_wing, theta, delta; smooth=false) body_aero = BodyAerodynamics([ram_wing]; va, omega) solver = Solver(body_aero; aerodynamic_model_type=VSM, @@ -85,8 +85,8 @@ end # Verify that linearization results match nonlinear results at operating point baseline_res = VortexStepMethod.solve!(solver, body_aero; log=false) - baseline_res = [solver.sol.force; solver.sol.moment; solver.sol.group_moment_dist] - coeff_baseline_res = [solver.sol.force_coeffs; solver.sol.moment_coeffs; solver.sol.group_moment_coeff_dist] + baseline_res = [solver.sol.force; solver.sol.moment; solver.sol.moment_unrefined_dist] + coeff_baseline_res = [solver.sol.force_coeffs; solver.sol.moment_coeffs; solver.sol.cm_unrefined_dist] @test baseline_res ≈ lin_res @test coeff_baseline_res ≈ coeff_lin_res @@ -137,13 +137,13 @@ end else throw(ArgumentError()) end - VortexStepMethod.group_deform!(ram_wing, reset_theta, reset_delta; smooth=false) + VortexStepMethod.unrefined_deform!(ram_wing, reset_theta, reset_delta; smooth=false) reinit!(body_aero; init_aero=false, va=reset_va, omega=reset_omega) # Get nonlinear solution nonlin_res = VortexStepMethod.solve!(solver, body_aero, nothing; log=false) - nonlin_res = [solver.sol.force; solver.sol.moment; solver.sol.group_moment_dist] - coeff_nonlin_res = [solver.sol.force_coeffs; solver.sol.moment_coeffs; solver.sol.group_moment_coeff_dist] + nonlin_res = [solver.sol.force; solver.sol.moment; solver.sol.moment_unrefined_dist] + coeff_nonlin_res = [solver.sol.force_coeffs; solver.sol.moment_coeffs; solver.sol.cm_unrefined_dist] @test nonlin_res ≉ baseline_res @test coeff_nonlin_res ≉ baseline_res @@ -220,7 +220,7 @@ end for (combo_name, active_indices, perturbation, idx_mappings) in combination_tests @testset "$combo_name" begin # Start with a fresh model for each combination test - VortexStepMethod.group_deform!(ram_wing, theta, delta; smooth=false) + VortexStepMethod.unrefined_deform!(ram_wing, theta, delta; smooth=false) reinit!(body_aero; init_aero=false, va, omega) # Create the appropriate input vector for this combination @@ -250,7 +250,7 @@ end # Get baseline results baseline_res = VortexStepMethod.solve!(solver, body_aero; log=false) - baseline_res = [solver.sol.force; solver.sol.moment; solver.sol.group_moment_dist] + baseline_res = [solver.sol.force; solver.sol.moment; solver.sol.moment_unrefined_dist] # Should match the linearization result @test baseline_res ≈ lin_res_combo @@ -272,12 +272,12 @@ end perturbed_input[idx_mappings.delta_idxs] : delta # Apply to nonlinear model - VortexStepMethod.group_deform!(ram_wing, perturbed_theta, perturbed_delta; smooth=false) + VortexStepMethod.unrefined_deform!(ram_wing, perturbed_theta, perturbed_delta; smooth=false) reinit!(body_aero; init_aero=false, va=perturbed_va, omega=perturbed_omega) # Get nonlinear solution with perturbation nonlin_res = VortexStepMethod.solve!(solver, body_aero; log=false) - nonlin_res = [solver.sol.force; solver.sol.moment; solver.sol.group_moment_dist] + nonlin_res = [solver.sol.force; solver.sol.moment; solver.sol.moment_unrefined_dist] # Compute linearized prediction using our specialized Jacobian lin_prediction = lin_res_combo + jac_combo * perturbation diff --git a/test/data/solver/wings/solver_test_wing.yaml b/test/data/solver/wings/solver_test_wing.yaml index e69de29b..14969bce 100644 --- a/test/data/solver/wings/solver_test_wing.yaml +++ b/test/data/solver/wings/solver_test_wing.yaml @@ -0,0 +1,12 @@ +wing_sections: + headers: [airfoil_id, LE_x, LE_y, LE_z, TE_x, TE_y, TE_z] + data: + - [1, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0] + - [1, 0.0, -1.0, 0.0, 1.0, -1.0, 0.0] + +wing_airfoils: + alpha_range: [-10, 10, 1] + reynolds: 1000000 + headers: [airfoil_id, type, info_dict] + data: + - [1, polars, {csv_file_path: ""}] diff --git a/test/plotting/test_plotting.jl b/test/plotting/test_plotting.jl index 385abcef..29fe4a20 100644 --- a/test/plotting/test_plotting.jl +++ b/test/plotting/test_plotting.jl @@ -1,5 +1,5 @@ using VortexStepMethod -using ControlPlots +using GLMakie using Test # Resolve repo data directory for ram air kite assets @@ -29,7 +29,7 @@ if !@isdefined ram_wing foil_src = joinpath(_ram_data_dir, "ram_air_kite_foil.dat") cp(body_src, body_path; force=true) cp(foil_src, foil_path; force=true) - ram_wing = RamAirWing(body_path, foil_path; alpha_range=deg2rad.(-1:1), delta_range=deg2rad.(-1:1)) + ram_wing = ObjWing(body_path, foil_path; alpha_range=deg2rad.(-1:1), delta_range=deg2rad.(-1:1)) end function create_body_aero() @@ -56,6 +56,7 @@ function create_body_aero() INVISCID) # Step 3: Initialize aerodynamics + refine!(wing) body_aero = BodyAerodynamics([wing]) # Set inflow conditions vel_app = [cos(alpha), 0.0, sin(alpha)] .* v_a @@ -63,35 +64,26 @@ function create_body_aero() body_aero end -plt.ioff() @testset "Plotting" begin - fig = plt.figure(figsize=(14, 14)) - res = plt.plot([1,2,3]) - @test fig isa plt.PyPlot.Figure - @test res isa Vector{plt.PyObject} save_dir = tempdir() - save_plot(fig, save_dir, "plot") - @test isfile(joinpath(save_dir, "plot.pdf")) - safe_rm(joinpath(save_dir, "plot.pdf")) - show_plot(fig) body_aero = create_body_aero() if Sys.islinux() fig = plot_geometry( body_aero, "Rectangular_wing_geometry"; - data_type=".pdf", + data_type=".png", save_path=save_dir, is_save=true, is_show=false) - @test fig isa plt.PyPlot.Figure - @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_angled_view.pdf")) - safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_angled_view.pdf")) - @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_front_view.pdf")) - safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_front_view.pdf")) - @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_side_view.pdf")) - safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_side_view.pdf")) - @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_top_view.pdf")) - safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_top_view.pdf")) + @test fig isa Figure + @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_angled_view.png")) + safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_angled_view.png")) + @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_front_view.png")) + safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_front_view.png")) + @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_side_view.png")) + safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_side_view.png")) + @test isfile(joinpath(save_dir, "Rectangular_wing_geometry_top_view.png")) + safe_rm(joinpath(save_dir, "Rectangular_wing_geometry_top_view.png")) # Step 5: Initialize the solvers vsm_solver = Solver(body_aero; aerodynamic_model_type=VSM) @@ -110,7 +102,7 @@ plt.ioff() ["VSM", "LLT"], title="Spanwise Distributions" ) - @test fig isa plt.PyPlot.Figure + @test fig isa Figure # Step 8: Plot polar curves v_a = 20.0 # Magnitude of inflow velocity [m/s] @@ -123,20 +115,20 @@ plt.ioff() angle_type="angle_of_attack", v_a=v_a, title="Rectangular Wing Polars", - data_type=".pdf", + data_type=".png", save_path=save_dir, is_save=true, is_show=false ) - @test fig isa plt.PyPlot.Figure - @test isfile(joinpath(save_dir, "Rectangular_Wing_Polars.pdf")) - safe_rm(joinpath(save_dir, "Rectangular_Wing_Polars.pdf")) + @test fig isa Figure + @test isfile(joinpath(save_dir, "Rectangular_Wing_Polars.png")) + safe_rm(joinpath(save_dir, "Rectangular_Wing_Polars.png")) # Step 9: Test polar data plotting + # ram_wing is an ObjWing - no refine! needed body_aero = BodyAerodynamics([ram_wing]) fig = plot_polar_data(body_aero; is_show=false) - @test fig isa plt.PyPlot.Figure + @test fig isa Figure end end -plt.ion() nothing \ No newline at end of file diff --git a/test/ram_geometry/test_kite_geometry.jl b/test/ram_geometry/test_kite_geometry.jl index 74b6fd9c..7df146dd 100644 --- a/test/ram_geometry/test_kite_geometry.jl +++ b/test/ram_geometry/test_kite_geometry.jl @@ -165,42 +165,42 @@ using Serialization @test R_b_p2 ≈ I(3) end - @testset "RamAirWing Construction" begin - wing = RamAirWing(test_obj_path, test_dat_path; remove_nan=true) - + @testset "ObjWing Construction" begin + wing = ObjWing(test_obj_path, test_dat_path; remove_nan=true) + @test wing.n_panels == 56 # Default value @test wing.spanwise_distribution == UNCHANGED @test wing.spanwise_direction ≈ [0.0, 1.0, 0.0] - @test length(wing.sections) > 0 # Should have sections now + @test length(wing.unrefined_sections) > 0 # Should have sections now @test wing.mass ≈ 1.0 @test wing.radius ≈ r rtol=1e-2 @test wing.gamma_tip ≈ π/4 rtol=1e-2 - @test !isnan(wing.sections[1].aero_data[3][end]) - @test !isnan(wing.sections[1].aero_data[4][end]) - @test !isnan(wing.sections[1].aero_data[5][end]) - - wing = RamAirWing(test_obj_path, test_dat_path; remove_nan=false) - @test isnan(wing.sections[1].aero_data[3][end]) - @test isnan(wing.sections[1].aero_data[4][end]) - @test isnan(wing.sections[1].aero_data[5][end]) + @test !isnan(wing.unrefined_sections[1].aero_data[3][end]) + @test !isnan(wing.unrefined_sections[1].aero_data[4][end]) + @test !isnan(wing.unrefined_sections[1].aero_data[5][end]) + + wing = ObjWing(test_obj_path, test_dat_path; remove_nan=false) + @test isnan(wing.unrefined_sections[1].aero_data[3][end]) + @test isnan(wing.unrefined_sections[1].aero_data[4][end]) + @test isnan(wing.unrefined_sections[1].aero_data[5][end]) end @testset "Wing Deformation" begin - # Create a RamAirWing for testing - wing = RamAirWing(test_obj_path, test_dat_path; remove_nan=true) + # Create an ObjWing for testing (no refine! needed - fully complete) + wing = ObjWing(test_obj_path, test_dat_path; remove_nan=true) body_aero = BodyAerodynamics([wing]) - + # Store original TE point for comparison i = length(body_aero.panels) ÷ 2 original_te_point = copy(body_aero.panels[i].TE_point_1) - + # Apply deformation with non-zero angles - theta_dist = fill(deg2rad(30.0), wing.n_panels) # 10 degrees twist - delta_dist = fill(deg2rad(5.0), wing.n_panels) # 5 degrees trailing edge deflection - + theta_dist = fill(deg2rad(30.0), wing.n_panels) # 30 degrees twist for all panels + delta_dist = fill(deg2rad(5.0), wing.n_panels) # 5 degrees TE deflection for all panels + VortexStepMethod.deform!(wing, theta_dist, delta_dist) VortexStepMethod.reinit!(body_aero) - + # Check if TE point changed after deformation deformed_te_point = copy(body_aero.panels[i].TE_point_1) @test !isapprox(original_te_point, deformed_te_point, atol=1e-2) @@ -208,19 +208,56 @@ using Serialization @test deformed_te_point[2] ≈ original_te_point[2] atol=1e-2 # right hand rule @test deformed_te_point[1] < original_te_point[1] # right hand rule @test body_aero.panels[i].delta ≈ deg2rad(5.0) - + # Reset deformation with zero angles zero_theta_dist = zeros(wing.n_panels) zero_delta_dist = zeros(wing.n_panels) - + VortexStepMethod.deform!(wing, zero_theta_dist, zero_delta_dist) VortexStepMethod.reinit!(body_aero) - + # Check if TE point returned to original position reset_te_point = copy(body_aero.panels[i].TE_point_1) @test original_te_point ≈ reset_te_point atol=1e-4 end + + @testset "First and Last Section Deformation with unrefined_deform!" begin + # Create an ObjWing with a small number of panels and unrefined sections + wing = ObjWing(test_obj_path, test_dat_path; + n_panels=4, n_unrefined_sections=2, remove_nan=true) + + # Store original TE points from all refined_sections + # Wing has n_panels+1 sections (5 sections for 4 panels) + n_sections = wing.n_panels + 1 + original_te_points = [copy(wing.refined_sections[i].TE_point) + for i in 1:n_sections] + + # Apply unrefined_deform! with non-zero angles (2 groups, each controlling 2 panels) + theta_angles = [deg2rad(15.0), deg2rad(20.0)] + delta_angles = [deg2rad(5.0), deg2rad(10.0)] + + VortexStepMethod.unrefined_deform!(wing, theta_angles, delta_angles; smooth=false) + + # Check that all sections' TE points have been deformed + for i in 1:n_sections + deformed_te = wing.refined_sections[i].TE_point + original_te = original_te_points[i] + + if i == 1 + # First section should be deformed + @test !isapprox(original_te, deformed_te, atol=1e-6) + @info "Section 1 (first): original=$original_te, deformed=$deformed_te" + elseif i == n_sections + # Last section (n_panels+1) should be deformed + @test !isapprox(original_te, deformed_te, atol=1e-6) + @info "Section $(n_sections) (last): original=$original_te, deformed=$deformed_te" + else + # Intermediate sections should also be deformed + @test !isapprox(original_te, deformed_te, atol=1e-6) + end + end + end rm(test_obj_path) rm(test_dat_path) -end \ No newline at end of file +end diff --git a/test/runtests.jl b/test/runtests.jl index 6c6f92cd..593b9c81 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,10 +2,27 @@ using Test, VortexStepMethod # Make paths robust (avoid cd("..")) cd(@__DIR__) # ensure we're in test/ no matter how tests are launched -include("TestSupport.jl") -using .TestSupport +include("test_data_utils.jl") + +# Support selective test execution via ]test test_args=["pattern"] +const test_patterns = isempty(ARGS) ? String[] : ARGS println("Running tests...") +if !isempty(test_patterns) + println("Filtering tests matching: ", test_patterns) +end + +# Helper to check if a test file matches any pattern +function should_run_test(test_path::String) + isempty(test_patterns) && return true + for pattern in test_patterns + # Match directory (e.g., "solver") or specific file (e.g., "test_unrefined_dist") + if occursin(pattern, test_path) + return true + end + end + return false +end # keep your env check as-is... const build_is_production_build_env_name = "BUILD_IS_PRODUCTION_BUILD" @@ -15,24 +32,26 @@ const build_is_production_build = let v = get(ENV, build_is_production_build_env end::Bool @testset verbose = true "Testing VortexStepMethod..." begin - if build_is_production_build + if build_is_production_build && should_run_test("bench") include("bench.jl") end - include("body_aerodynamics/test_body_aerodynamics.jl") - include("body_aerodynamics/test_results.jl") - include("filament/test_bound_filament.jl") - include("filament/test_semi_infinite_filament.jl") - include("panel/test_panel.jl") - include("plotting/test_plotting.jl") - include("polars/test_polars.jl") - include("ram_geometry/test_kite_geometry.jl") - include("settings/test_settings.jl") - include("solver/test_solver.jl") - include("VortexStepMethod/test_VortexStepMethod.jl") - include("wake/test_wake.jl") - include("wing_geometry/test_wing_geometry.jl") - include("yaml_geometry/test_yaml_geometry.jl") - include("Aqua.jl") + should_run_test("body_aerodynamics/test_body_aerodynamics.jl") && include("body_aerodynamics/test_body_aerodynamics.jl") + should_run_test("body_aerodynamics/test_results.jl") && include("body_aerodynamics/test_results.jl") + should_run_test("test_refinement_validation.jl") && include("test_refinement_validation.jl") + should_run_test("filament/test_bound_filament.jl") && include("filament/test_bound_filament.jl") + should_run_test("filament/test_semi_infinite_filament.jl") && include("filament/test_semi_infinite_filament.jl") + should_run_test("panel/test_panel.jl") && include("panel/test_panel.jl") + should_run_test("plotting/test_plotting.jl") && include("plotting/test_plotting.jl") + should_run_test("polars/test_polars.jl") && include("polars/test_polars.jl") + should_run_test("ram_geometry/test_kite_geometry.jl") && include("ram_geometry/test_kite_geometry.jl") + should_run_test("settings/test_settings.jl") && include("settings/test_settings.jl") + should_run_test("solver/test_solver.jl") && include("solver/test_solver.jl") + should_run_test("solver/test_unrefined_dist.jl") && include("solver/test_unrefined_dist.jl") + should_run_test("VortexStepMethod/test_VortexStepMethod.jl") && include("VortexStepMethod/test_VortexStepMethod.jl") + should_run_test("wake/test_wake.jl") && include("wake/test_wake.jl") + should_run_test("wing_geometry/test_wing_geometry.jl") && include("wing_geometry/test_wing_geometry.jl") + should_run_test("yaml_geometry/test_yaml_geometry.jl") && include("yaml_geometry/test_yaml_geometry.jl") + should_run_test("Aqua.jl") && include("Aqua.jl") end nothing diff --git a/test/settings/test_settings.jl b/test/settings/test_settings.jl index c7412894..42fb5e5b 100644 --- a/test/settings/test_settings.jl +++ b/test/settings/test_settings.jl @@ -1,8 +1,3 @@ -using Pkg -if ! ("Test" ∈ keys(Pkg.project().dependencies)) - using TestEnv; TestEnv.activate() -end - using VortexStepMethod using Test @@ -16,6 +11,6 @@ using Test @test vss.wings isa Vector{WingSettings} @test length(vss.wings) == 2 io = IOBuffer(repr(vss)) - @test countlines(io) == 40 # Updated to match new output format + @test countlines(io) == 42 # Updated to match new output format end nothing diff --git a/test/solver/solver_settings.yaml b/test/solver/solver_settings.yaml index 689f915a..142c0c1f 100644 --- a/test/solver/solver_settings.yaml +++ b/test/solver/solver_settings.yaml @@ -2,7 +2,6 @@ wings: - name: "solver_test_wing" geometry_file: "test/solver/solver_test_wing.yaml" n_panels: 4 - n_groups: 2 spanwise_panel_distribution: COSINE spanwise_direction: [0.0, 1.0, 0.0] remove_nan: true diff --git a/test/solver/test_solver.jl b/test/solver/test_solver.jl index 1d60061a..5004986b 100644 --- a/test/solver/test_solver.jl +++ b/test/solver/test_solver.jl @@ -11,7 +11,9 @@ using Test # Test Solver constructor with VSMSettings settings = VSMSettings(settings_file) wing = Wing(settings) - body_aero = BodyAerodynamics([wing]) + refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) solver = Solver(body_aero, settings) # Verify solver properties match settings diff --git a/test/solver/test_unrefined_dist.jl b/test/solver/test_unrefined_dist.jl new file mode 100644 index 00000000..5e2d131a --- /dev/null +++ b/test/solver/test_unrefined_dist.jl @@ -0,0 +1,148 @@ +using VortexStepMethod +using LinearAlgebra +using Test + +@testset "Unrefined Arrays Tests" begin + @testset "Unrefined section array aggregation" begin + # Create a simple wing with unrefined sections + n_panels = 20 + n_unrefined_sections = 5 # 5 unrefined sections + + # Create a test wing settings file + settings_file = create_temp_wing_settings("solver", "solver_test_wing.yaml"; alpha=5.0, beta=0.0, wind_speed=10.0) + + try + # Modify settings to use specific panel configuration + settings = VSMSettings(settings_file) + settings.wings[1].n_panels = n_panels + settings.solver_settings.n_panels = n_panels + + # Create wing and solver + wing = Wing(settings) + refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + solver = Solver(body_aero, settings) + + # Set conditions and solve + va = [10.0, 0.0, 0.0] + set_va!(body_aero, va) + sol = solve!(solver, body_aero) + + # Test 1: Unrefined arrays exist and have correct size + @test length(sol.cl_unrefined_dist) == wing.n_unrefined_sections + @test length(sol.cd_unrefined_dist) == wing.n_unrefined_sections + @test length(sol.cm_unrefined_dist) == wing.n_unrefined_sections + + # Test 2: Unrefined arrays are not all zeros (solver computed them) + @test !all(sol.cl_unrefined_dist .== 0.0) + @test !all(sol.cd_unrefined_dist .== 0.0) + + # Test 3: Verify unrefined coefficients are averaged from refined panels + # refined_panel_mapping maps each refined panel to its unrefined section index + for unrefined_idx in 1:wing.n_unrefined_sections + # Find all refined panels that map to this unrefined section + refined_panel_indices = findall(x -> x == unrefined_idx, wing.refined_panel_mapping) + + if !isempty(refined_panel_indices) + # Calculate expected average from refined panel coefficients + expected_cl = sum(sol.cl_dist[refined_panel_indices]) / length(refined_panel_indices) + expected_cd = sum(sol.cd_dist[refined_panel_indices]) / length(refined_panel_indices) + expected_cm = sum(sol.cm_dist[refined_panel_indices]) / length(refined_panel_indices) + + # Check if unrefined coefficients match expected averages + # Handle NaN values that can occur in INVISCID models + if isnan(expected_cl) + @test isnan(sol.cl_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cl_unrefined_dist[unrefined_idx], expected_cl, rtol=1e-10) + end + if isnan(expected_cd) + @test isnan(sol.cd_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cd_unrefined_dist[unrefined_idx], expected_cd, rtol=1e-10) + end + if isnan(expected_cm) + @test isnan(sol.cm_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cm_unrefined_dist[unrefined_idx], expected_cm, rtol=1e-10) + end + end + end + + # Test 4: Verify physical consistency (lift coefficients should be positive at positive AoA) + # Skip test if values are NaN + if !any(isnan.(sol.cl_unrefined_dist)) + @test all(sol.cl_unrefined_dist .> 0.0) + end + + finally + rm(settings_file; force=true) + end + end + + @testset "Unrefined arrays with different panel counts" begin + # Test with various panel/section combinations + test_cases = [ + (n_panels=40, n_unrefined_expected=21), # From YAML file sections + (n_panels=30, n_unrefined_expected=21), + (n_panels=24, n_unrefined_expected=21), + ] + + for (n_panels, n_unrefined_expected) in test_cases + settings_file = create_temp_wing_settings("solver", "solver_test_wing.yaml"; alpha=5.0, beta=0.0, wind_speed=10.0) + + try + settings = VSMSettings(settings_file) + settings.wings[1].n_panels = n_panels + settings.solver_settings.n_panels = n_panels + + wing = Wing(settings) + refine!(wing) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + solver = Solver(body_aero, settings) + + va = [10.0, 0.0, 0.0] + set_va!(body_aero, va) + sol = solve!(solver, body_aero) + + # Verify arrays have correct size + @test length(sol.cl_unrefined_dist) == wing.n_unrefined_sections + @test length(sol.cd_unrefined_dist) == wing.n_unrefined_sections + @test length(sol.cm_unrefined_dist) == wing.n_unrefined_sections + + # Verify unrefined coefficients are computed correctly using mapping + for unrefined_idx in 1:wing.n_unrefined_sections + refined_panel_indices = findall(x -> x == unrefined_idx, wing.refined_panel_mapping) + + if !isempty(refined_panel_indices) + expected_cl = sum(sol.cl_dist[refined_panel_indices]) / length(refined_panel_indices) + expected_cd = sum(sol.cd_dist[refined_panel_indices]) / length(refined_panel_indices) + expected_cm = sum(sol.cm_dist[refined_panel_indices]) / length(refined_panel_indices) + + # Handle NaN for all coefficients + if isnan(expected_cl) + @test isnan(sol.cl_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cl_unrefined_dist[unrefined_idx], expected_cl, rtol=1e-10) + end + if isnan(expected_cd) + @test isnan(sol.cd_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cd_unrefined_dist[unrefined_idx], expected_cd, rtol=1e-10) + end + if isnan(expected_cm) + @test isnan(sol.cm_unrefined_dist[unrefined_idx]) + else + @test isapprox(sol.cm_unrefined_dist[unrefined_idx], expected_cm, rtol=1e-10) + end + end + end + + finally + rm(settings_file; force=true) + end + end + end +end diff --git a/test/test_data_utils.jl b/test/test_data_utils.jl index 23b54c59..13fc97ce 100644 --- a/test/test_data_utils.jl +++ b/test/test_data_utils.jl @@ -68,7 +68,6 @@ rm(settings_file) function create_temp_wing_settings(module_name, wing_file; name="test_wing", n_panels=4, - n_groups=2, spanwise_panel_distribution="COSINE", spanwise_direction=[0.0, 1.0, 0.0], remove_nan=true, @@ -87,7 +86,6 @@ function create_temp_wing_settings(module_name, wing_file; "name" => name, "geometry_file" => wing_file_path, "n_panels" => n_panels, - "n_groups" => n_groups, "spanwise_panel_distribution" => spanwise_panel_distribution, "spanwise_direction" => spanwise_direction, "remove_nan" => remove_nan, diff --git a/test/test_refinement_validation.jl b/test/test_refinement_validation.jl new file mode 100644 index 00000000..0555b707 --- /dev/null +++ b/test/test_refinement_validation.jl @@ -0,0 +1,86 @@ +using VortexStepMethod +using Test + +@testset "Refinement Validation" begin + @testset "Error when refinement forgotten" begin + wing = Wing(20; spanwise_distribution=LINEAR) + add_section!(wing, [0,10,0], [1,10,0], INVISCID) + add_section!(wing, [0,-10,0], [1,-10,0], INVISCID) + + # Should error without refinement + @test_throws ArgumentError BodyAerodynamics([wing]) + + # Should work after refinement + refine!(wing) + body_aero = BodyAerodynamics([wing]) + @test length(body_aero.panels) == 20 + end + + @testset "Multiple refine! calls work correctly" begin + wing = Wing(20; spanwise_distribution=LINEAR) + add_section!(wing, [0,10,0], [1,10,0], INVISCID) + add_section!(wing, [0,-10,0], [1,-10,0], INVISCID) + + # Multiple calls should work (not idempotent - re-refines each time) + refine!(wing) + n1 = length(wing.refined_sections) + + refine!(wing) + n2 = length(wing.refined_sections) + + @test n1 == n2 == 21 + end + + @testset "YAML wing deformation support after refinement" begin + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=4) + + # After refinement, should have non_deformed_sections + refine!(wing) + @test !isempty(wing.non_deformed_sections) + @test length(wing.non_deformed_sections) == 5 + + # unrefined_deform! should work + @test_nowarn VortexStepMethod.unrefined_deform!(wing, [0.1, 0.2], nothing) + end + + @testset "ObjWing deformation support" begin + data_dir = joinpath(dirname(@__DIR__), "data", "ram_air_kite") + wing = ObjWing( + joinpath(data_dir, "ram_air_kite_body.obj"), + joinpath(data_dir, "ram_air_kite_foil.dat"); + n_panels=20, + n_unrefined_sections=2, + prn=false + ) + + # ObjWing creates non_deformed_sections in constructor (no refine! needed) + @test !isempty(wing.non_deformed_sections) + @test length(wing.non_deformed_sections) == 21 + + # unrefined_deform! should work + @test_nowarn VortexStepMethod.unrefined_deform!(wing, deg2rad.([5.0, -5.0]), nothing) + end + + @testset "Refinement populates all required fields" begin + wing = Wing(10; spanwise_distribution=COSINE) + add_section!(wing, [0,5,0], [1,5,0], INVISCID) + add_section!(wing, [0,-5,0], [1,-5,0], INVISCID) + + refine!(wing) + + # Check refined_sections populated + @test length(wing.refined_sections) == 11 + + # Check non_deformed_sections populated + @test length(wing.non_deformed_sections) == 11 + + # Check theta_dist and delta_dist resized + @test length(wing.theta_dist) == 10 + @test length(wing.delta_dist) == 10 + + # Check all zeros initially + @test all(wing.theta_dist .== 0.0) + @test all(wing.delta_dist .== 0.0) + end +end diff --git a/test/wake/test_wake.jl b/test/wake/test_wake.jl index 156e4ec9..e6d604f5 100644 --- a/test/wake/test_wake.jl +++ b/test/wake/test_wake.jl @@ -20,7 +20,8 @@ using VortexStepMethod try # Create wing and body aerodynamics with known good geometry - wing = RamAirWing(body_path, foil_path; n_panels=56) # Use default panels + # ObjWing is fully complete - no refine! needed + wing = ObjWing(body_path, foil_path; n_panels=56) # Use default panels body_aero = BodyAerodynamics([wing]) # Test that frozen_wake! doesn't throw errors diff --git a/test/wing_geometry/test_wing_geometry.jl b/test/wing_geometry/test_wing_geometry.jl index d2d01f0f..74704048 100644 --- a/test/wing_geometry/test_wing_geometry.jl +++ b/test/wing_geometry/test_wing_geometry.jl @@ -1,7 +1,7 @@ using Test using LinearAlgebra using VortexStepMethod -using VortexStepMethod: Wing, Section, add_section!, refine_mesh_by_splitting_provided_sections!, refine_aerodynamic_mesh! +using VortexStepMethod: Wing, Section, add_section!, refine_mesh_by_splitting_provided_sections!, refine! import Base: == """ @@ -25,15 +25,15 @@ end @test example_wing.n_panels == 10 @test example_wing.spanwise_distribution == LINEAR @test example_wing.spanwise_direction ≈ [0.0, 1.0, 0.0] - @test length(example_wing.sections) == 0 + @test length(example_wing.unrefined_sections) == 0 end @testset "Add section" begin example_wing = Wing(10) add_section!(example_wing, [0.0, 0.0, 0.0], [-1.0, 0.0, 0.0], INVISCID) - @test length(example_wing.sections) == 1 + @test length(example_wing.unrefined_sections) == 1 - section = example_wing.sections[1] + section = example_wing.unrefined_sections[1] @test section.LE_point ≈ [0.0, 0.0, 0.0] @test section.TE_point ≈ [-1.0, 0.0, 0.0] @test section.aero_model === INVISCID @@ -57,7 +57,7 @@ end add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], POLAR_VECTORS, aero_data) # Check if NaNs were removed consistently - cleaned_data = wing.sections[1].aero_data + cleaned_data = wing.unrefined_sections[1].aero_data @test length(cleaned_data[1]) == 18 # 21 - 3 NaN positions @test !any(isnan, cleaned_data[2]) # cl @test !any(isnan, cleaned_data[3]) # cd @@ -84,7 +84,7 @@ end add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], POLAR_MATRICES, aero_data) # Check if NaNs were removed consistently - cleaned_data = wing.sections[1].aero_data + cleaned_data = wing.unrefined_sections[1].aero_data @test !any(isnan, cleaned_data[3]) # cl @test !any(isnan, cleaned_data[4]) # cd @test !any(isnan, cleaned_data[5]) # cm @@ -98,7 +98,7 @@ end add_section!(example_wing, [0.0, 1.0, 0.0], [0.0, 1.0, 0.0], INVISCID) add_section!(example_wing, [0.0, -1.0, 0.0], [0.0, -1.0, 0.0], INVISCID) add_section!(example_wing, [0.0, -1.5, 0.0], [0.0, -1.5, 0.0], INVISCID) - refine_aerodynamic_mesh!(example_wing) + refine!(example_wing) sections = example_wing.refined_sections # Test right to left order @@ -106,7 +106,7 @@ end add_section!(example_wing_1, [0.0, -1.5, 0.0], [0.0, -1.5, 0.0], INVISCID) add_section!(example_wing_1, [0.0, -1.0, 0.0], [0.0, -1.0, 0.0], INVISCID) add_section!(example_wing_1, [0.0, 1.0, 0.0], [0.0, 1.0, 0.0], INVISCID) - refine_aerodynamic_mesh!(example_wing_1) + refine!(example_wing_1) sections_1 = example_wing_1.refined_sections # Test random order @@ -114,7 +114,7 @@ end add_section!(example_wing_2, [0.0, 1.0, 0.0], [0.0, 1.0, 0.0], INVISCID) add_section!(example_wing_2, [0.0, -1.5, 0.0], [0.0, -1.5, 0.0], INVISCID) add_section!(example_wing_2, [0.0, -1.0, 0.0], [0.0, -1.0, 0.0], INVISCID) - refine_aerodynamic_mesh!(example_wing_2) + refine!(example_wing_2) sections_2 = example_wing_2.refined_sections for i in eachindex(sections) @@ -133,7 +133,7 @@ end wing = Wing(n_panels; spanwise_distribution=LINEAR) add_section!(wing, [0.0, span/2, 0.0], [-1.0, span/2, 0.0], INVISCID) add_section!(wing, [0.0, -span/2, 0.0], [-1.0, -span/2, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections @test length(sections) == wing.n_panels + 1 @@ -149,7 +149,7 @@ end wing = Wing(n_panels; spanwise_distribution=COSINE) add_section!(wing, [0.0, span/2, 0.0], [-1.0, span/2, 0.0], INVISCID) add_section!(wing, [0.0, -span/2, 0.0], [-1.0, -span/2, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections @test length(sections) == wing.n_panels + 1 @@ -173,8 +173,8 @@ end add_section!(wing, [0.0, span/2, 0.0], [-1.0, span/2, 0.0], INVISCID) add_section!(wing, [0.0, -span/2, 0.0], [-1.0, -span/2, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) - sections = wing.sections + refine!(wing) + sections = wing.unrefined_sections @test length(sections) == wing.n_panels + 1 @test sections[1].LE_point ≈ [0.0, span/2, 0.0] @test sections[1].TE_point ≈ [-1.0, span/2, 0.0] @@ -188,7 +188,7 @@ end add_section!(wing, [0.0, span/2, 0.0], [-1.0, span/2, 0.0], INVISCID) add_section!(wing, [0.0, -span/2, 0.0], [-1.0, -span/2, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections @test length(sections) == wing.n_panels + 1 @test sections[1].LE_point ≈ [0.0, span/2, 0.0] @@ -206,7 +206,7 @@ end add_section!(wing, [0.0, y, 0.0], [-1.0, y, 0.0], INVISCID) end - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections @test length(sections) == wing.n_panels + 1 @@ -226,7 +226,7 @@ end add_section!(wing, [0.0, 5.0, 0.0], [-1.0, 5.0, 0.0], INVISCID) add_section!(wing, [0.0, -5.0, 0.0], [-1.0, -5.0, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections # Calculate expected quarter-chord points @@ -284,7 +284,7 @@ end add_section!(wing, [0.0, 0.0, 0.0], [-1.0, 0.0, 0.0], LEI_AIRFOIL_BREUKELS, (2.0, 0.5)) add_section!(wing, [0.0, -span/2, 0.0], [-1.0, -span/2, 0.0], LEI_AIRFOIL_BREUKELS, (4.0, 1.0)) - refine_aerodynamic_mesh!(wing) + refine!(wing) sections = wing.refined_sections @test length(sections) == wing.n_panels + 1 @@ -316,7 +316,7 @@ end add_section!(wing, [0.0, -1.0, 0.0], [1.0, -1.0, 0.0], INVISCID) add_section!(wing, [0.0, -2.0, 0.0], [1.0, -2.0, 0.0], INVISCID) - refine_aerodynamic_mesh!(wing) + refine!(wing) new_sections = wing.refined_sections @test length(new_sections) - 1 == 6 @@ -332,4 +332,142 @@ end @test section.aero_model == INVISCID end end + + @testset "Refined panel mapping" begin + # Test that refined panel mapping actually maps each panel to its closest unrefined panel + + @testset "LINEAR distribution" begin + n_panels = 20 + span = 10.0 + + wing = Wing(n_panels; spanwise_distribution=LINEAR) + # 3 sections + add_section!(wing, [0.0, span/2, 0.0], [1.0, span/2, 0.0], INVISCID) + add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], INVISCID) + add_section!(wing, [0.0, -span/2, 0.0], [1.0, -span/2, 0.0], INVISCID) + + refine!(wing) + + @test length(wing.refined_panel_mapping) == n_panels + + # Manually verify each refined panel is mapped to its closest unrefined section + n_unrefined_sections = length(wing.unrefined_sections) + for refined_panel_idx in 1:n_panels + # Calculate refined panel center + le_mid = (wing.refined_sections[refined_panel_idx].LE_point + + wing.refined_sections[refined_panel_idx+1].LE_point) / 2 + te_mid = (wing.refined_sections[refined_panel_idx].TE_point + + wing.refined_sections[refined_panel_idx+1].TE_point) / 2 + refined_center = (le_mid + te_mid) / 2 + + # Find closest unrefined section manually + min_dist = Inf + closest_idx = 1 + for unrefined_section_idx in 1:n_unrefined_sections + le_point = wing.unrefined_sections[unrefined_section_idx].LE_point + te_point = wing.unrefined_sections[unrefined_section_idx].TE_point + unrefined_center = (le_point + te_point) / 2 + + dist = norm(refined_center - unrefined_center) + if dist < min_dist + min_dist = dist + closest_idx = unrefined_section_idx + end + end + + # Verify mapping is correct + @test wing.refined_panel_mapping[refined_panel_idx] == closest_idx + end + end + + @testset "COSINE distribution" begin + n_panels = 30 + span = 20.0 + + wing = Wing(n_panels; spanwise_distribution=COSINE) + # 4 sections + add_section!(wing, [0.0, span/2, 0.0], [1.0, span/2, 0.0], INVISCID) + add_section!(wing, [0.0, span/6, 0.0], [1.0, span/6, 0.0], INVISCID) + add_section!(wing, [0.0, -span/6, 0.0], [1.0, -span/6, 0.0], INVISCID) + add_section!(wing, [0.0, -span/2, 0.0], [1.0, -span/2, 0.0], INVISCID) + + refine!(wing) + + @test length(wing.refined_panel_mapping) == n_panels + + # Verify each panel is mapped to its closest unrefined section + n_unrefined_sections = length(wing.unrefined_sections) + for refined_panel_idx in 1:n_panels + # Calculate refined panel center + le_mid = (wing.refined_sections[refined_panel_idx].LE_point + + wing.refined_sections[refined_panel_idx+1].LE_point) / 2 + te_mid = (wing.refined_sections[refined_panel_idx].TE_point + + wing.refined_sections[refined_panel_idx+1].TE_point) / 2 + refined_center = (le_mid + te_mid) / 2 + + # Find closest unrefined section manually + min_dist = Inf + closest_idx = 1 + for unrefined_section_idx in 1:n_unrefined_sections + le_point = wing.unrefined_sections[unrefined_section_idx].LE_point + te_point = wing.unrefined_sections[unrefined_section_idx].TE_point + unrefined_center = (le_point + te_point) / 2 + + dist = norm(refined_center - unrefined_center) + if dist < min_dist + min_dist = dist + closest_idx = unrefined_section_idx + end + end + + # Verify mapping is correct + @test wing.refined_panel_mapping[refined_panel_idx] == closest_idx + end + end + + @testset "SPLIT_PROVIDED distribution" begin + n_panels = 12 + + wing = Wing(n_panels; spanwise_distribution=SPLIT_PROVIDED) + # 4 sections + add_section!(wing, [0.0, 6.0, 0.0], [1.0, 6.0, 0.0], INVISCID) + add_section!(wing, [0.0, 2.0, 0.0], [1.0, 2.0, 0.0], INVISCID) + add_section!(wing, [0.0, -2.0, 0.0], [1.0, -2.0, 0.0], INVISCID) + add_section!(wing, [0.0, -6.0, 0.0], [1.0, -6.0, 0.0], INVISCID) + + refine!(wing) + + @test length(wing.refined_panel_mapping) == n_panels + + # Verify each panel is mapped to its closest unrefined section + n_unrefined_sections = length(wing.unrefined_sections) + for refined_panel_idx in 1:n_panels + # Calculate refined panel center + le_mid = (wing.refined_sections[refined_panel_idx].LE_point + + wing.refined_sections[refined_panel_idx+1].LE_point) / 2 + te_mid = (wing.refined_sections[refined_panel_idx].TE_point + + wing.refined_sections[refined_panel_idx+1].TE_point) / 2 + refined_center = (le_mid + te_mid) / 2 + + # Find closest unrefined section manually + min_dist = Inf + closest_idx = 1 + for unrefined_section_idx in 1:n_unrefined_sections + le_point = wing.unrefined_sections[unrefined_section_idx].LE_point + te_point = wing.unrefined_sections[unrefined_section_idx].TE_point + unrefined_center = (le_point + te_point) / 2 + + dist = norm(refined_center - unrefined_center) + if dist < min_dist + min_dist = dist + closest_idx = unrefined_section_idx + end + end + + # Verify mapping is correct + @test wing.refined_panel_mapping[refined_panel_idx] == closest_idx + end + end + + end end \ No newline at end of file diff --git a/test/yaml_geometry/test_wing_constructor.jl b/test/yaml_geometry/test_wing_constructor.jl index b7b30a45..bdd19f5d 100644 --- a/test/yaml_geometry/test_wing_constructor.jl +++ b/test/yaml_geometry/test_wing_constructor.jl @@ -37,31 +37,30 @@ using Logging # Use the actual YAML file from the test data cp(test_data_path("yaml_geometry", "simple_wing.yaml"), test_yaml_path; force=true) - wing = Wing(test_yaml_path; n_panels=4, n_groups=2) - + wing = Wing(test_yaml_path; n_panels=4) + @test wing isa Wing @test wing.n_panels == 4 - @test wing.n_groups == 2 @test wing.spanwise_distribution == LINEAR @test wing.spanwise_direction ≈ [0.0, 1.0, 0.0] - @test length(wing.sections) == 2 # simple_wing has 2 sections + @test length(wing.unrefined_sections) == 2 # simple_wing has 2 sections # Test section coordinates (sections are sorted by spanwise position) # simple_wing.yaml has: [1, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0] and [1, 0.0, -1.0, 0.0, 1.0, -1.0, 0.0] # Sorted by y-coordinate: y=1.0, y=-1.0 - @test wing.sections[1].LE_point ≈ [0.0, 1.0, 0.0] - @test wing.sections[1].TE_point ≈ [1.0, 1.0, 0.0] - @test wing.sections[2].LE_point ≈ [0.0, -1.0, 0.0] - @test wing.sections[2].TE_point ≈ [1.0, -1.0, 0.0] + @test wing.unrefined_sections[1].LE_point ≈ [0.0, 1.0, 0.0] + @test wing.unrefined_sections[1].TE_point ≈ [1.0, 1.0, 0.0] + @test wing.unrefined_sections[2].LE_point ≈ [0.0, -1.0, 0.0] + @test wing.unrefined_sections[2].TE_point ≈ [1.0, -1.0, 0.0] # Test that sections have polar data - @test wing.sections[1].aero_model == POLAR_VECTORS - @test wing.sections[2].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[2].aero_model == POLAR_VECTORS # Test polar data is loaded - @test wing.sections[1].aero_data isa Tuple - @test length(wing.sections[1].aero_data) == 4 - @test length(wing.sections[1].aero_data[1]) >= 3 # at least 3 alpha points + @test wing.unrefined_sections[1].aero_data isa Tuple + @test length(wing.unrefined_sections[1].aero_data) == 4 + @test length(wing.unrefined_sections[1].aero_data[1]) >= 3 # at least 3 alpha points end @testset "Wing Constructor Parameters" begin @@ -72,13 +71,11 @@ using Logging wing = Wing( test_yaml_path; n_panels=8, - n_groups=4, spanwise_distribution=COSINE, remove_nan=false ) - + @test wing.n_panels == 8 - @test wing.n_groups == 4 @test wing.spanwise_distribution == COSINE @test !wing.remove_nan end @@ -104,8 +101,8 @@ wing_airfoils: wing = suppress_warnings(() -> Wing(test_yaml_path; n_panels=2)) # Should fall back to INVISCID model - @test wing.sections[1].aero_model == INVISCID - @test wing.sections[1].aero_data === nothing + @test wing.unrefined_sections[1].aero_model == INVISCID + @test wing.unrefined_sections[1].aero_data === nothing end @testset "Sections Without Polar Files" begin @@ -129,7 +126,7 @@ wing_airfoils: wing = suppress_warnings(() -> Wing(test_yaml_path; n_panels=2)) # Should fall back to INVISCID model - @test wing.sections[1].aero_model == INVISCID + @test wing.unrefined_sections[1].aero_model == INVISCID end @testset "Invalid Parameters" begin @@ -148,10 +145,11 @@ wing_airfoils: - [1, polars, {csv_file_path: "polars/1.csv"}] """ write(test_yaml_path, yaml_content) - - # Test invalid n_panels/n_groups combination - @test_throws ArgumentError Wing(test_yaml_path; n_panels=5, n_groups=2) - + + # Test # n_groups=0 (no grouping functionality) - backward compatibility + wing_no_groups = Wing(test_yaml_path; n_panels=4) + @test wing_no_groups.n_panels == 4 + # Test invalid spanwise direction @test_throws ArgumentError Wing(test_yaml_path; spanwise_direction=[1.0, 0.0, 0.0]) end @@ -172,10 +170,10 @@ wing_airfoils: wing = Wing(subdir_yaml_path; n_panels=2) # Should successfully load polar data with relative path - @test wing.sections[1].aero_model == POLAR_VECTORS - @test wing.sections[1].aero_data isa Tuple - @test wing.sections[2].aero_model == POLAR_VECTORS - @test wing.sections[2].aero_data isa Tuple + @test wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[1].aero_data isa Tuple + @test wing.unrefined_sections[2].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[2].aero_data isa Tuple # Cleanup rm(subdir; recursive=true) @@ -184,21 +182,20 @@ wing_airfoils: @testset "Complex Wing Geometry" begin # Use the actual complex_wing.yaml file cp(test_data_path("yaml_geometry", "complex_wing.yaml"), test_yaml_path; force=true) - - wing = Wing(test_yaml_path; n_panels=12, n_groups=3) - + + wing = Wing(test_yaml_path; n_panels=12) + @test wing.n_panels == 12 - @test wing.n_groups == 3 - @test length(wing.sections) == 7 + @test length(wing.unrefined_sections) == 7 # Test that different airfoil_ids get different polar data - @test wing.sections[1].aero_model == POLAR_VECTORS - @test wing.sections[2].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[2].aero_model == POLAR_VECTORS # Verify geometric progression along wingspan - @test wing.sections[1].LE_point[2] == 5.0 # First section at y=5 - @test wing.sections[4].LE_point[2] == 0.0 # Middle section at y=0 - @test wing.sections[7].LE_point[2] == -5.0 # Last section at y=-5 + @test wing.unrefined_sections[1].LE_point[2] == 5.0 # First section at y=5 + @test wing.unrefined_sections[4].LE_point[2] == 0.0 # Middle section at y=0 + @test wing.unrefined_sections[7].LE_point[2] == -5.0 # Last section at y=-5 end @testset "VSMSettings Constructor" begin @@ -211,20 +208,18 @@ wing_airfoils: settings.wings = [WingSettings( geometry_file=simple_wing_file, n_panels=6, - n_groups=3, spanwise_panel_distribution=COSINE )] - + # Test Wing constructor with VSMSettings wing = Wing(settings) - + @test wing isa Wing @test wing.n_panels == 6 - @test wing.n_groups == 3 @test wing.spanwise_distribution == COSINE - @test length(wing.sections) == 2 - @test wing.sections[1].aero_model == POLAR_VECTORS - @test wing.sections[2].aero_model == POLAR_VECTORS + @test length(wing.unrefined_sections) == 2 + @test wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test wing.unrefined_sections[2].aero_model == POLAR_VECTORS end @testset "Shared Test Data Usage" begin @@ -233,33 +228,31 @@ wing_airfoils: @test isfile(simple_wing_file) # Test basic Wing construction with shared data - wing = Wing(simple_wing_file; n_panels=4, n_groups=2) + wing = Wing(simple_wing_file; n_panels=4) @test wing isa Wing @test wing.n_panels == 4 - @test wing.n_groups == 2 - @test length(wing.sections) == 2 - + @test length(wing.unrefined_sections) == 2 + # Test complex wing construction complex_wing_file = test_data_path("yaml_geometry", "complex_wing.yaml") @test isfile(complex_wing_file) - - complex_wing = Wing(complex_wing_file; n_panels=12, n_groups=3) + + complex_wing = Wing(complex_wing_file; n_panels=12) @test complex_wing isa Wing @test complex_wing.n_panels == 12 - @test complex_wing.n_groups == 3 - @test length(complex_wing.sections) == 7 + @test length(complex_wing.unrefined_sections) == 7 # Verify polar data is loaded from shared files - @test complex_wing.sections[1].aero_model == POLAR_VECTORS - @test complex_wing.sections[1].aero_data isa Tuple + @test complex_wing.unrefined_sections[1].aero_model == POLAR_VECTORS + @test complex_wing.unrefined_sections[1].aero_data isa Tuple # Test with module-specific convenience function - create a standard wing for this test standard_wing_file = simple_wing_file # Use simple_wing as our "standard" @test isfile(standard_wing_file) - standard_wing = Wing(standard_wing_file; n_panels=2, n_groups=1) + standard_wing = Wing(standard_wing_file; n_panels=2) @test standard_wing isa Wing - @test length(standard_wing.sections) == 2 + @test length(standard_wing.unrefined_sections) == 2 end # Cleanup after all tests diff --git a/test/yaml_geometry/test_yaml_geometry.jl b/test/yaml_geometry/test_yaml_geometry.jl index 12daa54f..58924a91 100644 --- a/test/yaml_geometry/test_yaml_geometry.jl +++ b/test/yaml_geometry/test_yaml_geometry.jl @@ -4,4 +4,5 @@ using Test # Include specific test files for better organization include("test_load_polar_data.jl") include("test_wing_constructor.jl") + include("test_yaml_wing_deformation.jl") end diff --git a/test/yaml_geometry/test_yaml_wing_deformation.jl b/test/yaml_geometry/test_yaml_wing_deformation.jl new file mode 100644 index 00000000..6194e676 --- /dev/null +++ b/test/yaml_geometry/test_yaml_wing_deformation.jl @@ -0,0 +1,312 @@ +using VortexStepMethod +using LinearAlgebra +using Test + +@testset "YAML Wing Deformation Tests" begin + @testset "Simple Wing Deformation" begin + # Load existing simple_wing.yaml + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=4) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Store original TE point for comparison + i = length(body_aero.panels) ÷ 2 + original_te_point = copy(body_aero.panels[i].TE_point_1) + original_le_point = copy(body_aero.panels[i].LE_point_1) + + # Apply deformation with non-zero angles (panel-level) + theta_dist = fill(deg2rad(30.0), wing.n_panels) # 30 degrees twist per panel + delta_dist = fill(deg2rad(5.0), wing.n_panels) # 5 degrees TE deflection per panel + + VortexStepMethod.deform!(wing, theta_dist, delta_dist) + VortexStepMethod.reinit!(body_aero) + + # Check if TE point changed after deformation + deformed_te_point = copy(body_aero.panels[i].TE_point_1) + deformed_le_point = copy(body_aero.panels[i].LE_point_1) + + # TE point should change significantly due to twist and deflection + @test !isapprox(original_te_point, deformed_te_point, atol=1e-2) + @test deformed_te_point[3] < original_te_point[3] # TE should move down with positive twist + + # LE point stays fixed (deform! rotates TE around LE) + @test isapprox(original_le_point, deformed_le_point, atol=1e-4) + + # Check delta is set correctly + @test body_aero.panels[i].delta ≈ deg2rad(5.0) + + # Reset deformation with zero angles + zero_theta_dist = zeros(wing.n_panels) + zero_delta_dist = zeros(wing.n_panels) + + VortexStepMethod.deform!(wing, zero_theta_dist, zero_delta_dist) + VortexStepMethod.reinit!(body_aero) + + # Check if TE point returned to original position + reset_te_point = copy(body_aero.panels[i].TE_point_1) + reset_le_point = copy(body_aero.panels[i].LE_point_1) + @test original_te_point ≈ reset_te_point atol=1e-4 + @test original_le_point ≈ reset_le_point atol=1e-4 + @test body_aero.panels[i].delta ≈ 0.0 atol=1e-4 + end + + @testset "Complex Wing Deformation" begin + # Load existing complex_wing.yaml with multiple sections + complex_wing_file = test_data_path("yaml_geometry", "complex_wing.yaml") + wing = Wing(complex_wing_file; n_panels=12) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Store original points for multiple panels + original_points = [] + test_indices = [1, length(body_aero.panels) ÷ 2, length(body_aero.panels)] + for i in test_indices + push!(original_points, ( + LE=copy(body_aero.panels[i].LE_point_1), + TE=copy(body_aero.panels[i].TE_point_1) + )) + end + + # Apply spanwise-varying deformation (panel-level) + n = wing.n_panels + theta_dist = [deg2rad(10.0 * i / n) for i in 1:n] # Linear twist distribution + delta_dist = [deg2rad(-5.0 + 10.0 * i / n) for i in 1:n] # Varying deflection + + VortexStepMethod.deform!(wing, theta_dist, delta_dist) + VortexStepMethod.reinit!(body_aero) + + # Check that different panels have different deformations + for (idx, i) in enumerate(test_indices) + deformed_te = body_aero.panels[i].TE_point_1 + deformed_le = body_aero.panels[i].LE_point_1 + + # TE points should have changed due to deformation + @test !isapprox(original_points[idx].TE, deformed_te, atol=1e-2) + # LE points stay fixed (deform! rotates TE around LE) + @test isapprox(original_points[idx].LE, deformed_le, atol=1e-4) + end + + # Check that the deformation is applied correctly + # First panel should have smaller theta, last panel should have larger theta + @test body_aero.panels[1].delta < body_aero.panels[end].delta + + # Reset and verify + VortexStepMethod.deform!(wing, zeros(wing.n_panels), zeros(wing.n_panels)) + VortexStepMethod.reinit!(body_aero) + + for (idx, i) in enumerate(test_indices) + reset_te = body_aero.panels[i].TE_point_1 + reset_le = body_aero.panels[i].LE_point_1 + @test original_points[idx].TE ≈ reset_te atol=1e-4 + @test original_points[idx].LE ≈ reset_le atol=1e-4 + @test body_aero.panels[i].delta ≈ 0.0 atol=1e-4 + end + end + + @testset "Multiple Reinit Calls with NTuple aero_data" begin + # This test specifically checks the NTuple handling fix + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=4) + refine!(wing) + + # Verify that sections have NTuple aero_data (for wings with simple polars) + # or other valid AeroData types + @test wing.unrefined_sections[1].aero_data !== nothing + + # Perform multiple reinit! calls to ensure NTuple handling works + for _ in 1:5 + VortexStepMethod.reinit!(wing) + end + + # Wing should still be valid after multiple reinits + @test wing.unrefined_sections[1].aero_data !== nothing + # Verify refined_sections and non_deformed_sections are properly populated + @test length(wing.refined_sections) == wing.n_panels + 1 + @test length(wing.non_deformed_sections) == wing.n_panels + 1 + end + + @testset "Deformation with BodyAerodynamics Reinit" begin + # Test that reinit! on BodyAerodynamics properly handles deformed wings + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=4) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Apply deformation + theta_dist = fill(deg2rad(15.0), wing.n_panels) + delta_dist = fill(deg2rad(3.0), wing.n_panels) + VortexStepMethod.deform!(wing, theta_dist, delta_dist) + VortexStepMethod.reinit!(body_aero) + + # Store state after deformation + i = length(body_aero.panels) ÷ 2 + + # Multiple reinit calls should work without errors + for _ in 1:3 + VortexStepMethod.reinit!(body_aero; + va=zeros(3), + omega=zeros(3), + init_aero=true, + + ) + end + + # Panel should maintain deformation + @test body_aero.panels[i].delta ≈ deg2rad(3.0) atol=1e-6 + end + + @testset "Edge Cases" begin + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=2) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Test zero deformation + VortexStepMethod.deform!(wing, zeros(wing.n_panels), zeros(wing.n_panels)) + VortexStepMethod.reinit!(body_aero) + @test all(p.delta ≈ 0.0 for p in body_aero.panels) + + # Test large deformation angles + theta_dist = fill(deg2rad(60.0), wing.n_panels) + delta_dist = fill(deg2rad(30.0), wing.n_panels) + + # Should not error even with large angles + VortexStepMethod.deform!(wing, theta_dist, delta_dist) + VortexStepMethod.reinit!(body_aero) + @test all(p.delta ≈ deg2rad(30.0) for p in body_aero.panels) + + # Test negative angles + theta_dist = fill(deg2rad(-20.0), wing.n_panels) + delta_dist = fill(deg2rad(-10.0), wing.n_panels) + VortexStepMethod.deform!(wing, theta_dist, delta_dist) + VortexStepMethod.reinit!(body_aero) + @test all(p.delta ≈ deg2rad(-10.0) for p in body_aero.panels) + end + + @testset "Panel to Section Angle Mapping" begin + # Test that panel angles are correctly averaged to section angles + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=4) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Create varying panel angles + theta_panel = [10.0, 20.0, 30.0, 40.0] # degrees per panel + delta_panel = [5.0, 10.0, 15.0, 20.0] # degrees per panel + + # Apply deformation + VortexStepMethod.deform!(wing, deg2rad.(theta_panel), deg2rad.(delta_panel)) + + # Verify section angles by checking the geometry + # Section 1: should use panel 1 angle = 10° + # Section 2: should avg panels 1,2 = (10+20)/2 = 15° + # Section 3: should avg panels 2,3 = (20+30)/2 = 25° + # Section 4: should avg panels 3,4 = (30+40)/2 = 35° + # Section 5: should use panel 4 angle = 40° + + # We can verify this by checking that delta values are correct + VortexStepMethod.reinit!(body_aero) + + # Each panel gets its delta directly + @test body_aero.panels[1].delta ≈ deg2rad(5.0) atol=1e-6 + @test body_aero.panels[2].delta ≈ deg2rad(10.0) atol=1e-6 + @test body_aero.panels[3].delta ≈ deg2rad(15.0) atol=1e-6 + @test body_aero.panels[4].delta ≈ deg2rad(20.0) atol=1e-6 + end + + @testset "unrefined_deform! Maps to Panels" begin + # Test that unrefined_deform! correctly maps unrefined sections to panels + # Use complex_wing which has 7 unrefined sections + complex_wing_file = test_data_path("yaml_geometry", "complex_wing.yaml") + wing = Wing(complex_wing_file; n_panels=12) + refine!(wing) + body_aero = BodyAerodynamics([wing]) + + # Verify we have 7 unrefined sections + @test wing.n_unrefined_sections == 7 + + # Create unrefined section angles (7 sections) + # These will be mapped to panels via refined_panel_mapping + theta_unrefined = deg2rad.([10.0, 15.0, 20.0, 25.0, 20.0, 15.0, 10.0]) + delta_unrefined = deg2rad.([5.0, 7.5, 10.0, 12.5, 10.0, 7.5, 5.0]) + + # Apply using unrefined_deform! + VortexStepMethod.unrefined_deform!(wing, theta_unrefined, delta_unrefined) + VortexStepMethod.reinit!(body_aero) + + # Each panel should have the delta from its mapped unrefined section + for i in 1:wing.n_panels + unrefined_idx = wing.refined_panel_mapping[i] + expected_delta = delta_unrefined[unrefined_idx] + @test body_aero.panels[i].delta ≈ expected_delta atol=1e-6 + end + end + + @testset "Smooth vs Non-Smooth Deformation" begin + # Create test wing with 2 unrefined sections, refined to 40 panels + simple_wing_file = test_data_path("yaml_geometry", "simple_wing.yaml") + wing = Wing(simple_wing_file; n_panels=40) + refine!(wing) + @test wing.n_unrefined_sections == 2 + + # Define varying input angles at unrefined section level + delta_input = deg2rad.([0.0, 10.0]) + + # Test 1: Non-smooth deformation has step-wise discontinuities + VortexStepMethod.unrefined_deform!(wing, nothing, delta_input; smooth=false) + delta_nonsmooth = copy(wing.delta_dist) + + # Verify step-wise pattern: panels in same unrefined section have identical angles + for i in 1:wing.n_panels + unrefined_idx = wing.refined_panel_mapping[i] + @test delta_nonsmooth[i] ≈ delta_input[unrefined_idx] atol=1e-10 + end + + # Verify discontinuities exist at unrefined section boundaries + # Find boundary indices (where panel mapping changes) + max_gradient_nonsmooth = 0.0 + for i in 1:(wing.n_panels-1) + if wing.refined_panel_mapping[i] != wing.refined_panel_mapping[i+1] + gradient = abs(delta_nonsmooth[i+1] - delta_nonsmooth[i]) + max_gradient_nonsmooth = max(max_gradient_nonsmooth, gradient) + end + end + @test max_gradient_nonsmooth > deg2rad(5.0) # Should have large jumps + + # Test 2: Smooth deformation is continuous + VortexStepMethod.unrefined_deform!(wing, nothing, delta_input; smooth=true) + delta_smooth = copy(wing.delta_dist) + + # Verify gradients between adjacent panels are small + max_gradient_smooth = maximum(abs.(diff(delta_smooth))) + @test max_gradient_smooth < deg2rad(3.0) # Should be smooth + + # Verify no sharp discontinuities + for i in 1:(wing.n_panels-1) + @test abs(delta_smooth[i+1] - delta_smooth[i]) < deg2rad(3.0) + end + + # Test 3: Smoothing reduces maximum gradient + @test max_gradient_smooth < max_gradient_nonsmooth + + # Test 4: Angles match input at unrefined section centers (both modes) + # For non-smooth: extract angle at center panel of each unrefined section + VortexStepMethod.unrefined_deform!(wing, nothing, delta_input; smooth=false) + for i in 1:wing.n_unrefined_sections + # Find panels belonging to this unrefined section + panel_indices = findall(==(i), wing.refined_panel_mapping) + center_panel_idx = panel_indices[div(length(panel_indices), 2) + 1] + @test wing.delta_dist[center_panel_idx] ≈ delta_input[i] atol=1e-10 + end + + # For smooth: angles at center should be close to input (tolerance larger due to smoothing) + VortexStepMethod.unrefined_deform!(wing, nothing, delta_input; smooth=true) + for i in 1:wing.n_unrefined_sections + panel_indices = findall(==(i), wing.refined_panel_mapping) + center_panel_idx = panel_indices[div(length(panel_indices), 2) + 1] + # Smoothing may shift values slightly, use absolute tolerance for small angles + @test wing.delta_dist[center_panel_idx] ≈ delta_input[i] atol=deg2rad(2.0) + end + end +end