diff --git a/.JETLSConfig.toml.default b/.JETLSConfig.toml.default index 31945eb..ae4d9c2 100644 --- a/.JETLSConfig.toml.default +++ b/.JETLSConfig.toml.default @@ -5,3 +5,7 @@ match_by = "code" match_type = "literal" severity = "off" path = "examples/**/*.jl" + +# No inferred-type inlay hints in the editor; the `end`-tag hints stay. +[inlay_hint.types] +enabled = false diff --git a/bin/install_awetrim b/bin/install_awetrim index 2675730..c6ce605 100755 --- a/bin/install_awetrim +++ b/bin/install_awetrim @@ -2,19 +2,25 @@ # Copyright (c) 2026 Uwe Fechner # SPDX-License-Identifier: MIT # -# bash script to install the develop branch of https://github.com/awegroup/AWETrim -# in the folder ../AWETrim +# bash script to install the feat/server-aero-forces branch of +# https://github.com/awegroup/AWETrim in the folder ../AWETrim. That branch is +# develop plus the POST /aero_forces endpoint examples/validate_lift_drag.jl +# needs; switch BRANCH back to develop once it is merged. # # AWETrim is a Python project, so it gets its own venv with the dev and server # extras installed in editable mode; bin/run_server (here and in AWETrim) expects -# exactly that layout: ../AWETrim/venv/bin/python. +# exactly that layout: ../AWETrim/venv/bin/python. With uv in PATH, uv creates +# the venv and installs into it; without it, python3's venv and pip are used, +# and pip is bootstrapped first in a venv that lacks it (one uv created). # # The checkout is a sibling of this repository, not a subdirectory, so it is # shared with the other workspaces that talk to the same server. # # Re-running the script is safe: an existing checkout is fetched and fast-forwarded # rather than re-cloned, and the venv is only created if it is missing. A checkout -# with local modifications is left alone apart from the dependency install. +# with local modifications is left alone apart from the dependency install. A +# branch that exists only in the local checkout (not pushed yet) is checked out +# as it is. # # At the end the optimizer's two caches in output/ are cleared (the failed-request # cache opt_failure_cache.yaml and the solution cache opt_chain_cache/): their keys @@ -24,7 +30,7 @@ set -euo pipefail REPO_URL="https://github.com/awegroup/AWETrim" -BRANCH="develop" +BRANCH="feat/server-aero-forces" TARGET="../AWETrim" if [[ $(basename "$(pwd)") == "bin" ]]; then @@ -40,7 +46,7 @@ for cmd in git python3; do fi done -if ! python3 -c "import venv" >/dev/null 2>&1; then +if ! command -v uv >/dev/null 2>&1 && ! python3 -c "import venv" >/dev/null 2>&1; then echo "ERROR: the python3 venv module is missing." >&2 echo "On Debian/Ubuntu install it with: sudo apt install python3-venv" >&2 exit 1 @@ -49,12 +55,23 @@ fi # --- clone or update ---------------------------------------------------------- if [[ -d $TARGET/.git ]]; then echo "Found an existing checkout in $(cd "$TARGET" && pwd), updating it ..." - git -C "$TARGET" fetch origin "$BRANCH" + on_origin=false + if git -C "$TARGET" ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + on_origin=true + git -C "$TARGET" fetch origin "$BRANCH" + elif ! git -C "$TARGET" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then + echo "ERROR: branch $BRANCH is neither on origin nor in the local checkout." >&2 + exit 1 + fi if [[ -n $(git -C "$TARGET" status --porcelain) ]]; then echo "WARNING: the checkout has local modifications, skipping the branch update." >&2 else git -C "$TARGET" checkout "$BRANCH" - git -C "$TARGET" merge --ff-only "origin/$BRANCH" + if $on_origin; then + git -C "$TARGET" merge --ff-only "origin/$BRANCH" + else + echo "Branch $BRANCH is not on origin; using the local branch as it is." + fi fi elif [[ -e $TARGET ]]; then echo "ERROR: $TARGET exists but is not a git repository." >&2 @@ -70,13 +87,25 @@ cd "$TARGET" # --- virtual environment ------------------------------------------------------ if [[ ! -x venv/bin/python ]]; then echo "Creating the virtual environment in $(pwd)/venv ..." - python3 -m venv venv + if command -v uv >/dev/null 2>&1; then + uv venv venv + else + python3 -m venv venv + fi fi echo "Using $(venv/bin/python --version)" echo "Installing AWETrim with the dev and server extras ..." -venv/bin/pip install --upgrade pip -venv/bin/pip install -e '.[dev,server]' +if command -v uv >/dev/null 2>&1; then + uv pip install --python venv/bin/python -e '.[dev,server]' +else + # A venv created by uv has no pip. + if ! venv/bin/python -m pip --version >/dev/null 2>&1; then + venv/bin/python -m ensurepip --upgrade + fi + venv/bin/python -m pip install --upgrade pip + venv/bin/python -m pip install -e '.[dev,server]' +fi # --- check --------------------------------------------------------------------- if ! venv/bin/python -c "import fastapi, uvicorn" 2>/dev/null; then @@ -89,4 +118,5 @@ echo "Clearing the optimizer caches in $RKC_ROOT/output ..." rm -f "$RKC_ROOT/output/opt_failure_cache.yaml" rm -rf "$RKC_ROOT/output/opt_chain_cache" -echo "Done. Start the AWETrim server with bin/run_server" +echo "Done. Start the AWETrim server with bin/run_server start;" +echo "a server that is already running keeps the old code until bin/run_server restart." diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index 44bde6e..4d83142 100644 --- a/data/fc_settings_reelout.yaml +++ b/data/fc_settings_reelout.yaml @@ -88,7 +88,8 @@ fc_settings: winch: # compliance of the force-mode winch (its gains are in wc_settings.yaml) and force guards compliance: 0.0 # Fixed at POSITION mode; REEL_OUT needs the drum free [-] entry_f_min: 350.0 # Entry-guard (guard_lfc) force floor, phases 0-2; not wc's f_low [N] - first_lap_force_frac: 0.93 # f_high x this in lap 1 AND for the startup solve; 1.0 = no lap-1 de-rating [-] + first_lap_force_frac: 0.96 # f_high x this in lap 1 AND for the startup solve; 1.0 = no lap-1 de-rating; + # 0.93 until 2026-10-31, see docs/fig8_tuning_log.md [-] reelout: # REEL_OUT winch and phase 5; kv/f_low/f_high/v_sat are in wc_settings.yaml reelout_l_max: 380.0 # Length at which reel-out stops; AWETrim's reopt ceiling is ~325 m [m] @@ -123,6 +124,11 @@ fc_settings: low_wind_speeds: [4.51, 5.15, 5.80, 6.40] # Wind speed at low_wind_height, ascending [m/s] low_wind_l_tether: [195.0, 195.0, 195.0, 150.0] # Tether length reel-out starts at; less guidance phase lag [m] low_wind_guess_el_center: [25.0, 27.0, 27.0, 29.0] # Startup guess; 29° at 195 m lands in a 470 W optimum [deg] + # With the 12.62 kg wing the Maasvlakte 11 m/s (14.2 at 100 m) startup has no solution at 150 m + # and converges from 160 m up (2026-10-31); 10 m/s (12.9) flies at 150 m. + high_wind_speed: 14.0 # Wind speed at low_wind_height from which reel-out starts at the length below; 0 = off [m/s] + high_wind_l_tether: 170.0 # Tether length reel-out starts at from high_wind_speed on; 160 failed the curvature + # gate at Maasvlakte 11 m/s, 180 laps >= 2.5 at Cabauw 8 m/s (2026-10-31) [m] run: # simulation conditions and pass criteria vsm_interval: 5 # Steps between VSM aero updates, 1 = tightest; at 10 the identification flights sink at depower 0.375 (2026-10-02) [-] @@ -133,3 +139,7 @@ fc_settings: entry_time: 62.0 # Settle time after park_time before the metrics [s] min_elevation: 6.5 # Floor criterion over the WHOLE run; also the reopt install gate [deg] min_span_frac: 0.7 # Pattern-SIZE criterion, fraction of f8_a / f8_b [-] + min_laps: 2.5 # Laps criterion: figure-eight laps in the scored window [-] + min_laps_high: 2.0 # The same from min_laps_high_wind on: Cabauw 9 and 10 m/s fly 30-32 s laps from + # their shortest feasible starts, 170 and 180 m, i.e. 2.0 laps (2026-11-01) [-] + min_laps_high_wind: 16.5 # Wind speed at low_wind_height from which min_laps_high applies; 0 = off [m/s] diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index af0c757..f7f4fc1 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -12,17 +12,27 @@ traj_opt: free_speed_reference_points: 6 # free_speed solves for the reference power AFTER the run; 0 = off [-] guess: # initial guess; seeds a multi-modal solve: changes the answer, not the run time - guess_a: 30.0 # width of the guess; azimuth spans ±guess_a [deg] - guess_b: 12.0 # height of the guess, peak to peak [deg] + guess_a: 32.0 # width of the guess; azimuth spans ±guess_a [deg] + guess_b: 14.0 # height of the guess, peak to peak [deg] guess_el_center: 29.0 # centre elevation of the guess; first knob to move on a 422 [deg] guess_el_center_high: 30.0 # centre elevation at/above the ref wind (a step, not a ramp) [deg] guess_el_center_wind_ref: 8.0 # wind at/above which guess_el_center_high applies [m/s] startup_retry_el_offsets: [-1.0, 2.0, -2.0] # seeds tried in order on a startup 422; [] never retries [deg] + # Guess sizes [guess_a, guess_b] tried at the base centre on a startup 422, before the offsets. + # rom_config_aerostructural2 (2026-10-28): 32x14 converges at 5, 6, 8, 8.25 m/s, 30x13 at 7, 8, + # 8.25 and 34x15 at 5, 6, 7, 8.25 m/s; no single size covers all; 26x11 at 5 m/s with the + # 12.62 kg wing (bridle 1.62 kg, 2026-10-30) where all the others fail [deg] + startup_retry_guess_sizes: [[30.0, 13.0], [34.0, 15.0], [26.0, 11.0]] + # Added to the starting tether when every seed above fails, and the model set up again there. + # Cabauw 4 m/s (2026-10-31) has no startup solution at 150, 155 or 160 m and converges at 165-175 m; + # Cabauw 10 m/s none at 160, 170 or 175 m and one at 180 m [m] + startup_retry_length_steps: [10.0, 20.0] seed: # depower seed of the solve, ramped with the wind - input_depower: 1.6 # depower SEED in metres of tape (server bounds 1.1-2.3), not depower_setpoint [m] + input_depower: 1.5 # depower SEED in metres of tape (server bounds 1.1-2.3), not depower_setpoint [m] input_depower_wind_ref: 7.0 # wind at which input_depower IS the seed [m/s] - input_depower_per_wind: 0.25 # tape added per m/s above that; one-sided [m/(m/s)] + input_depower_per_wind: 0.0 # tape added per m/s above that; one-sided; 0.25 seeded 8 m/s at 1.75 m, which + # fails on rom_config_aerostructural2 where 1.5 m converges [m/(m/s)] input_depower_seed_max: 1.85 # ramp ceiling, below the server's 2.3 m [m] # Estimate of the tape length the startup reply is FLOWN at, which sizes the turn-radius # request; 0 takes the seed above. Fitted 2026-10-05 to the startup replies of the base @@ -39,17 +49,21 @@ traj_opt: depower_offset: 0.0 # [-] box: # pattern box the optimizer solves UNDER; 0.0 keeps the server default (|az| <= 45.8, 0.6 <= el <= 51.6) - pattern_azimuth_max: 28.0 # closes the wide basin seen at 5.5 m/s; good runs fly ±18-25 [deg] - pattern_azimuth_max_high: 34.0 # cap at/above pattern_elevation_amplitude_max_wind_ref: under the 45° climb + pattern_azimuth_max: 36.0 # 28 until 2026-10-28; rom_config_aerostructural2 turns ~43 % less and needs a + # wider and taller box: no startup solve at 5-8.25 m/s under 28 x 8.5 [deg] + pattern_azimuth_max_high: 45.0 # cap at/above pattern_elevation_amplitude_max_wind_ref; 36 until 2026-10-31: + # Cabauw 9 m/s (17.4 there) converges at 170 m and 10 m/s (19.3) at 180 m only + # with 45 and a 20° half-span cap. Older: under the 45° climb # limit Cabauw 10 needs ±28.4-28.8° at 150 m and IPOPT 2-3° of room: 422 under # 28 and 29 at r_min 22.8 m, under 30 at 23.5 m (quadratic depower # conversion); converges under 31 and 32 (2026-10-03). 36 -> 34 (2026-10-05): # at 36 the re-optimizations at Cabauw 10 grew to ±32° x 21°, RMS d 1.61°; # at 34 ±22° x 16°, RMS d 1.44°, 22.7 instead of 20.4 kW [deg] - pattern_elevation_amplitude_max: 8.5 # largest elevation HALF-SPAN of the figure; 0.0 = off [deg]; + pattern_elevation_amplitude_max: 15.0 # largest elevation HALF-SPAN of the figure; 0.0 = off; 8.5 until 2026-10-28 [deg]; # 8.0 left Maasvlakte 3.5 m/s infeasible at 8.12° (2026-09-24) - pattern_elevation_amplitude_max_high: 13.0 # cap at/above the ref wind (a step, not a ramp) [deg] - pattern_elevation_amplitude_max_wind_ref: 13.2 # wind AT the height below at/above which the high cap + pattern_elevation_amplitude_max_high: 20.0 # cap at/above the ref wind (a step, not a ramp); 15 until 2026-10-31 [deg] + pattern_elevation_amplitude_max_wind_ref: 16.5 # 13.2 until 2026-10-31, when the high caps equalled the base ones. + # Older: wind AT the height below at/above which the high cap # applies: Maasvlakte 10 m/s is 12.9 there and flies under # 8°, Maasvlakte 11 (14.2) and Cabauw 7-10 (13.5-19.3) need # 10° or the startup solve loses its basin (2026-09-21); @@ -59,6 +73,13 @@ traj_opt: pattern_symmetric: true # mirror-symmetric figure about azimuth 0; the solve is multi-modal and # otherwise lands lopsided (Cabauw 2026-09-25: centre -2.3°, lobes 214/147 points) pattern_climb_angle_max: 45.0 # steepest climb of the path in the az/el plane; descending is free; 0 is off [°] + pattern_shape_wind_ref: 12.5 # wind at pattern_elevation_amplitude_max_wind_height at/above which the two + # _shape_high values below apply; 0 = off. With the 12.62 kg wing Maasvlakte + # 10 m/s (12.9 there) converges only without both (2026-10-31); 9 m/s is 11.6 [m/s] + pattern_symmetric_shape_high: false # pattern_symmetric at/above pattern_shape_wind_ref + pattern_climb_angle_max_shape_high: 0.0 # pattern_climb_angle_max at/above pattern_shape_wind_ref; 0 is off [°] + angle_of_attack_min: -7.0 # lowest trim angle of attack, the ROM's validity; .nan = the server's 0°, under which + # Cabauw 9 m/s (trims at -0.5 to +0.4°) has no solution (2026-10-31) [°] gates: # every returned path, at startup and after a re-optimization min_feasibility_margin: 0.82 # curvature gate AND request (as min_turn_radius); 0.0 = off; an 8 m/s number [-] diff --git a/data/wc_settings.yaml b/data/wc_settings.yaml index cd6e8b9..890d8df 100644 --- a/data/wc_settings.yaml +++ b/data/wc_settings.yaml @@ -73,9 +73,9 @@ wc_settings: max_acc: 8.0 # rate limit of v_set; the reel-out's acceleration_limit [m/s^2] damage_factor: 0.2 # winch damage weighting (unused here) [-] jerk_factor: 0.9 # jerk weighting in the damage calculation (unused here) [-] - # kv: `v_set = kv * sqrt(force)`, one value at every wind speed. It used to be - # a column of winch_kv_table.yaml (now winch_table.yaml), flat at 0.0408 from 3 to 12 m/s, and was - # removed from there 2026-10-02. The 10 m/s row was 0.039, identified + # kv: `v_set = kv * sqrt(force)`. Since 2026-10-30 the kv column of winch_table.yaml + # overrides this value (0.030 at 3-4 m/s, see there); before, it was flat at 0.0408 from + # 3 to 12 m/s and was no column from 2026-10-02. The 10 m/s row was 0.039, identified # 2026-08-27 (sweep below), and went back to the sweep's other passing value # 2026-09-22 — see the note under the sweep. 11 and 12 m/s fly 0.0408 # UNTESTED at the current tether and winch controller: the 2026-08-26 notes @@ -122,7 +122,7 @@ wc_settings: # 3.42 m/s mean, on the limit, so kv cannot lower its force: 0.043 made the # startup solve infeasible (2026-09-23). There the lever is f_high, see # docs/fig8_tuning_log.md. - kv: 0.0408 # v_set = kv * sqrt(force); same at every wind speed, see above [-] + kv: 0.0408 # v_set = kv * sqrt(force); the kv column of winch_table.yaml overrides it [-] # --- SPEED: how the force limits are enforced (PlanWinchSpeed.md, stage 4) -- force_limit: "soft" # "soft" (optimizer's tension curve) | "hard" (two limiters); winch_table.yaml overrides @@ -131,6 +131,6 @@ wc_settings: v_sat_beta: 10.0 # corner sharpness of the v_sat clamp; .inf = hard clamp [s/m] soft_lfc: true # also replace the LowerForceController with a reel-in line v_reel_in: -2.0 # reel-in speed at zero force [m/s] - reel_in_beta: 20.0 # sharpness of the line/curve handover; needs * kv * sqrt(f_low) >= 8 everywhere [s/m] + reel_in_beta: 22.0 # sharpness of the line/curve handover; needs * kv * sqrt(f_low) >= 8 everywhere [s/m] use_awe_trim: 0.0 # blend of the LOCAL law towards AWETrim's curve, in [0, 1]; the server gets opt_awe_trim [-] force_limit_tau: 1.0 # low-pass on the force before inverting; 0 = off [s] diff --git a/data/winch_table.yaml b/data/winch_table.yaml index d322d10..1f82313 100644 --- a/data/winch_table.yaml +++ b/data/winch_table.yaml @@ -1,11 +1,19 @@ # Wind-dependent winch-law parameters, interpolated linearly between rows and # CLAMPED outside the identified range rather than extrapolated. Read by # `winch_f_low(v_wind)` and `winch_force_limit(v_wind)`; each overrides the flat -# value of the same name in data/wc_settings.yaml. `kv` is no longer a column: -# it was 0.0408 at every wind speed, so the flat `kv` of data/wc_settings.yaml -# is used as is (its tuning notes moved there). Add a row and re-identify the curve +# value of the same name in data/wc_settings.yaml. Add a row and re-identify the curve # here, not a new key elsewhere, when another wind speed is tuned. # +# kv — gain of the reel-out law v_set = kv*sqrt(force), read by `winch_kv(v_wind)` +# (its tuning notes are at `kv` in data/wc_settings.yaml). It was flat at +# 0.0408 and no column from 2026-10-02 until 2026-10-30, when the kite got +# its 1.62 kg bridle (12.62 kg wing): the 4 m/s startup request then has no +# solution under the pattern box at 0.0408 or 0.050, and converges at 0.030 +# (2967 W) and 0.025 (3074 W), so 0.030 at 4 m/s, the measured value closest +# to the old one. 3.5 m/s fails at 0.030, 0.025, 0.018 and 0.023 and converges +# from 0.019 to 0.022 (1.73-1.67 kW), so 0.020 at 3.5 and, untested, at 3 m/s. +# The 5 m/s row holds 0.0408 so that 5 m/s and up fly as before. +# # f_low — force floor [N] below which the LowerForceController reels in. It has # to move with the wind because the winch's rating does not and the # kite's force does (with the square of the wind speed), so one value @@ -51,23 +59,38 @@ entries: - v_wind: 3.0 f_low: 350.0 force_limit: "soft" + kv: 0.020 + - v_wind: 3.5 + f_low: 525.0 + force_limit: "soft" + kv: 0.020 - v_wind: 4.0 f_low: 700.0 force_limit: "soft" + kv: 0.030 + - v_wind: 5.0 + f_low: 700.0 + force_limit: "soft" + kv: 0.0408 # Neutral for f_low (already flat from 4 to 9); force_limit is # "soft" throughout the table now, so this row no longer marks a step. - v_wind: 6.0 f_low: 700.0 force_limit: "soft" + kv: 0.0408 - v_wind: 9.0 f_low: 700.0 force_limit: "soft" + kv: 0.0408 - v_wind: 10.0 f_low: 700.0 force_limit: "soft" + kv: 0.0408 - v_wind: 11.0 f_low: 700.0 force_limit: "soft" + kv: 0.0408 - v_wind: 12.0 f_low: 700.0 force_limit: "soft" + kv: 0.0408 diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 7b2e9ce..5b191d3 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6455,3 +6455,230 @@ With V3Kite's linear tape map and the new AWETrim the Maasvlakte scenarios flew The -0.013 step gives d ln P / d rel_depower ~ -16. At -0.018: depower 0.268/0.268/0.287, measured 1962/11692/23126 W. The peak phase-4 force at 10 m/s rose from 7228 to 7735 N, above the 7200 N `f_high`; the other wind speeds were not re-flown. + +## 2026-10-28 — Lift/drag validation against the AWETrim ROM: not a valid comparison yet + +`examples/validate_lift_drag.jl` compares V3Kite's lift and drag with `rom_config_aerostructural2.yaml` +at the same power tape and apparent wind speed. The two sides are NOT trimmed under the same +condition, so the numbers are not a validation of either model: + +- V3Kite: parked at 150 m and 71-77° elevation, carrying its weight, the KCU's and the tether's; + angle of attack 3.5-4.8° (`span_mean_aoa`, the span mean of the VSM panels' geometric angle). +- ROM: its gravity-free trim at the centre of the wind window, `alpha = atan2(C_D, C_L) - theta_b(u_p)` + (AWETrim `system/rom_forces.py`); angle of attack 1.1-2.2° (the wing angle of its identification). + +| tape | v_a | V3Kite L / D | ROM trim L / D | ΔL | ΔD | +|--|--|--|--|--|--| +| 1.60 m | 15.3 | 1637 / 240 | 1676 / 265 | +2 % | +11 % | +| 1.60 m | 8.8 | 584 / 82 | 554 / 88 | -5 % | +7 % | +| 1.76 m | 15.3 | 1358 / 226 | 1408 / 253 | +4 % | +12 % | +| 1.76 m | 8.6 | 478 / 74 | 450 / 81 | -6 % | +9 % | + +V3Kite drag is the VSM wing plus all bridle lines, AWETrim's basis. Agreement within ±6 % in lift +and 7-12 % in drag comes from two different flight states, so it says nothing yet. A valid +comparison needs both models in the same trim condition: the same flight state (elevation, +gravity, KCU and tether loads) or the same angle of attack under one definition. + +## 2026-10-28 — Depower seed 1.6 -> 1.5 m for `rom_config_aerostructural2.yaml` + +With the new ROM (Oriol's, fitted to V3Kite, valid from 1.35 m of tape) every startup solve at +Maasvlakte 7 m/s failed, all four guess elevations. Replaying the exact startup request in AWETrim's +`ReeloutSession` (no simulation) isolates the seed `input_depower`: + +| ROM | seed [m] | result | +|--|--|--| +| flight-corrected | 1.6 | converged, 1.628 m, 11.7 kW | +| `rom_config_aerostructural.yaml` | 1.6 | converged, 1.329 m, 21.1 kW | +| `rom_config_aerostructural2.yaml` | 1.6 | IPOPT stuck near 2.0 m, alpha below its 0° bound | +| `rom_config_aerostructural2.yaml` | 1.5 | converged, 1.350 m, 17.8 kW | +| `rom_config_aerostructural2.yaml` | 1.4 | converged, 1.350 m, 17.8 kW | + +From 1.6 m IPOPT walks to the depowered side, where this ROM's trim angle of attack (about 1.5° +lower than the flight-corrected ROM's at the same tape) drops below the optimizer's 0° bound; +relaxing that bound to -5° does not help. The optimum is at the bottom of the band, 1.35 m. Only +7 m/s was checked; the seed ramp above 7 m/s (0.25 m per m/s) is unchanged and untested on this ROM. + +## 2026-10-28 — Startup on `rom_config_aerostructural2`: wider box, larger guess, size retries + +The scenario build with seed 1.5 m flew 4 and 7 m/s (all 10 criteria, power ratio 0.92 and 0.94, +18.4 kW at 7 m/s) and failed the startup solve at Maasvlakte 3.5, 5, 6, 8 and 8.25 m/s. The exact +startup requests, replayed in AWETrim's `ReeloutSession`: + +- seed 1.35-1.45 m, an alpha floor of -6° or a taller box alone: no change; +- `gain_roll_steering` -0.376 -> -0.666 (the flight-corrected ROM's): all five converge. The new + ROM turns ~43 % less. That is physical for the geometry it was identified on (Bart's), not a + fitting error, so the requests have to fit a kite that turns less. Failures show steering + and its rate on their bounds, `s_dot` near 0 and alpha below 0°. +- box 36° x 15° (half-spans) with a larger guess, seed 1.5 m: + +| guess | 5 | 6 | 7 | 8 | 8.25 m/s | +|--|--|--|--|--|--| +| 30 x 13 | fail | fail | OK | OK | OK | +| 32 x 14 | OK | OK | fail | OK | OK | +| 34 x 15 | OK | OK | OK | fail | OK | + +No single size covers all, so the guess is 32 x 14 and `startup_retry_guess_sizes` tries +30 x 13 and 34 x 15 at the same centre before the elevation offsets. The seed ramp +(`input_depower_per_wind` 0.25 -> 0) is gone: 8 m/s seeded at 1.75 m fails where 1.5 m converges. +3.5 m/s (195 m tether) fails with every guess, seed and box tried. Only the startup requests +were checked; the effect of the larger box on the flown runs is not. + +## 2026-10-30 — AWETrim flies V3Kite's bridle mass, 1.62 kg + +After AWETrim was merged up to its `main` (`feat/server-aero-forces` at `0947c52`) the scenario +build failed the startup solve at Maasvlakte 3.5, 4, 5, 8.5, 9, 10 and 11 m/s; 4 and 5 m/s had +flown on 2026-10-28 on the same commit (240de86) against the pre-merge AWETrim. AWETrim `aa25dfc` +(2026-10-29) adds the bridle (2.58 kg, "lines + pulleys") to the point-mass wing: 10.99 -> +13.58 kg, 22.0 kg airborne with the KCU. Replaying one failed 5 m/s request (default ROM) +in-process: with the bridle mass the march stalls from node 20 of the guess (alpha 27-29°) and +fails at node 25, IPOPT ends locally infeasible; with only the wing mass back at 11.0 kg it +converges at 7.5 kW. At 10 m/s the mass costs a third of the predicted power (12.5 vs 17.9 kW). + +`examples/validate_masses.jl` puts V3Kite's structure next to AWETrim's file: canopy 10.99 kg +(both), KCU 8.4 kg (both), bridle 1.62 kg in V3Kite (1.12 kg of lines at 724 kg/m³ plus five +0.1 kg point masses) against 2.58 kg, so 21.0 kg airborne against 22.0 kg (19.4 kg before +`aa25dfc`). Every `/init` now sends `system_config_path = awetrim_system_config()`, AWETrim's +LEI-V3 file with the bridle mass set to `AWETRIM_BRIDLE_MASS` = 1.62 kg, written to +`output/awetrim_system/` with the ROMs copied next to it. The file's content is part of the +request key, so the cached solutions of the 2.58 kg kite are not replayed. + +With `rom_config_aerostructural2` the same replay did not separate the mass from the rest of +the merge: 4 m/s converged before the merge and fails after it with either mass, 8.5 m/s the +other way round, and 5 m/s at seed 1.5 m fails at node 0 in every version. + +Flown at 1.62 kg, Maasvlakte 5 m/s still failed every startup retry: the march broke at node +28-30 of the guess. The replayed request (wing 12.62 kg) fails the same way with +`turn_radius_headroom` 1.0 (8.98 m) and with no turn-radius limit, with seed 1.35 m, 3 or 5° +lower, and 36 x 15; it converges with the canopy alone (10.99 kg, 6.3 kW), centred 3° higher +(6.2 kW) or at 26 x 11 (5.9 kW). `startup_retry_guess_sizes` gains `[26, 11]`, and with it +5 m/s flies: all 10 criteria, 6.1 kW measured, power ratio 0.99, 3 paths installed. + +## 2026-10-30 — `kv` 0.030 at 3-4 m/s: Maasvlakte 4 m/s flies with the 12.62 kg wing + +With the 1.62 kg bridle Maasvlakte 4 m/s (195 m) failed every startup retry, sizes and +elevation offsets from -6 to +5° included. Its exact requests were recorded +(`OPT_REQUEST_RECORD`) and replayed in AWETrim, which reproduces the failure and the failed ++3° retry. Converged: the wing at 10.99 kg (3.1 kW); the pattern box without `symmetric` +AND without `climb_angle_max` (2.5 kW, 61 s laps; either one alone, any other single +limit relaxed, or the full box restored after a relaxed solve all fail); and a softer winch, +`k_v` 0.030 (2967 W) or 0.025 (3074 W). No help: guess +2/+3°, 26 x 11 at +3°, seed +1.35 m, no turn-radius limit, `k_v` 0.050, `f_min` 400 or 1000 N, tether 150, 170 or 220 m. + +`kv` is a column of `winch_table.yaml` again (`winch_kv`): 0.030 at 3 and 4 m/s, 0.0408 +from 5 m/s up. Flown at 4 m/s: all 10 criteria, 3.1 kW measured, power ratio 0.99, +3 paths installed, 1.41 m/s mean reel-out speed. 3 m/s (Cabauw) flies 0.030 untested. + +Maasvlakte 3.5 m/s (195 m) at `k_v` 0.030 failed every startup retry too. Its recorded +request converges at `k_v` 0.019-0.022 (1.73-1.67 kW, also with the guess moved ±2°) and +fails at 0.015, 0.017, 0.018, 0.023, 0.025 and 0.030, with the light wing as the only other +converging variant (seed 1.35 m, +2° and the relaxed box fail). New 3.5 m/s row at `k_v` +0.020, and 0.020 at 3 m/s so that `k_v` does not rise as the wind drops (3 m/s untested). +Flown at 3.5 m/s: all 10 criteria, 1.9 kW measured, power ratio 1.02, 6 paths installed, +0.9 m/s mean reel-out speed. + +## 2026-10-31 — `first_lap_force_frac` 0.93 -> 0.96: Maasvlakte 9 m/s with the 12.62 kg wing + +Maasvlakte 8.5 m/s flies with the 1.62 kg bridle unchanged (all 10 criteria, 19.2 kW, power +ratio 0.91); 9 m/s failed every startup retry. Its recorded request, replayed in AWETrim, +converges with the light wing (18.3 kW), without the pattern box, with `symmetric` off alone +(17.4 kW, the climb limit alone does not help), or with the startup `f_max` (`f_high` x +`first_lap_force_frac`, 6696 N) raised to 6900 N (17.1 kW), 7200 N (18.2 kW) or 7500 N +(19.2 kW). No help: guess ±2°, 30 x 13, seed 1.75 m, `k_v` 0.036 or 0.045, no turn-radius +limit. `first_lap_force_frac` 0.96 (6912 N), the smallest step that converges, keeps the box; +it was lowered to 0.93 on 2026-09-24 because 9 m/s then crossed the 8400 N rating in lap 1. +Flown at 9 m/s: all 10 criteria (max force included), 17.8 kW measured, power ratio 0.89, +phase-4 peak 6893 N. The factor applies at every wind speed; 10 and 11 m/s are not yet flown +with it. + +## 2026-10-31 — High-wind shape: no symmetry, no climb limit from 12.5 m/s aloft + +Maasvlakte 10 m/s (12.9 m/s at 100 m) failed every startup retry with the 1.62 kg bridle and +`first_lap_force_frac` 0.96. Its recorded request converges with the light wing (17.1 kW), +without the box (16.9 kW), or with `symmetric` off AND no `climb_angle_max` (17.7 kW); either +alone, any other single box limit, `f_max` 7200 or 7500 N (also with `symmetric` off), `k_v` +0.039 or 0.045, seed 1.75 or 2.0 m and the guess +2° all fail. New `pattern_shape_wind_ref` +(12.5 m/s at `pattern_elevation_amplitude_max_wind_height`) with `pattern_symmetric_shape_high` +false and `pattern_climb_angle_max_shape_high` 0 (off). Flown at 10 m/s: all 10 criteria, +18.1 kW measured, power ratio 0.85, phase-4 peak 7008 N, cross-track RMS 0.82°; the four +installed paths are centred -0.7° to +2.4° in azimuth. It also reaches Cabauw from 7 m/s +(13.5 m/s aloft), not yet flown with it. + +## 2026-10-31 — High-wind start length: Maasvlakte 11 m/s starts at 180 m + +Maasvlakte 11 m/s (14.2 m/s at 100 m) failed every startup retry with the 1.62 kg bridle, the +high-wind shape included. Its recorded request at 150 m fails without the box too, and with seed +1.75 or 1.84 m (the light wing's optimum flies 1.84 m, far from the ROM's 2.25 m ceiling), ++3°, no turn-radius limit, `f_max` 7500 N, `k_v` 0.036 or 0.045; it converges with the light +wing (14.8 kW) and at a longer start: 160 m (17.0 kW), 165, 170 and 180 m (18.4 kW). New +`low_wind.high_wind_speed` (14.0 m/s at `low_wind_height`) and `high_wind_l_tether`. Flown at +160 m the startup converged but failed the curvature gate (margin 0.79 against 0.82, all four +radius retries 422); at 180 m: all 10 criteria, 17.8 kW measured, power ratio 0.82, phase-4 +peak 6720 N, cross-track RMS 0.78°. Cabauw from about 8 m/s is above 14 m/s aloft and starts at +180 m too, not yet flown. + +## 2026-10-31 — Cabauw with the 1.62 kg bridle; `reel_in_beta` 20 -> 22 + +Rebuilt with every 2026-10-30/31 change: Cabauw 5, 5.5, 5.75, 6, 6.25 and 7 m/s pass all 10 +criteria (19.7-21.8 kW). Cabauw 3 m/s threw before flying: `k_v` 0.020 at `f_low` 350 N needs +`reel_in_beta * k_v * sqrt(f_low) >= 8`, i.e. `reel_in_beta` >= 21.4 (Maasvlakte 3.5 m/s has +`f_low` 525 N and passed at 20). At 22: all 10 criteria, 4.5 kW measured, power ratio 0.99 (the +2026-10-26 scenario flew 2.0 kW at 0.71). + +Still failing, from recorded requests replayed in-process: +- 4 m/s (7.7 m/s at 100 m): the 150 m startup fails at every seed, guess size, box, radius limit + and `k_v` 0.030-0.0408; 165, 170 and 175 m converge, 155, 160 and 180 m do not. +- 8 m/s (15.5 aloft) starts at 180 m from `high_wind_speed`, converges, and fails laps >= 2.5 + (17.9 kW). A 150 m start fails, 160 and 170 m converge. +- 9 and 10 m/s (17.4 and 19.3 aloft) fail at 120-220 m, without the box or radius limit, with + the light wing, the old ROM, `f_max` 6000-15000 N, `k_v` 0.048, seed 1.8-2.2 m and +5 to +15°. + The 9 m/s request converges at 7 and 8 m/s and fails at 8.5, and converges at 9 m/s with + `alpha` 0.10 or 0.15 instead of 0.234: the wind aloft is past what the optimizer solves. The + 2026-10-26 scenarios at 9 and 10 m/s predate the bridle mass and `rom_config_aerostructural2`. + +## 2026-10-31 — Startup length retry; high-wind start 180 -> 170 m + +New `startup_retry_length_steps` (`traj_opt.yaml`, [20.0]): when no seed converges, the run is set +up again with the tether that much longer (`setup_with_startup`, through the new `l_tether` run +input). Cabauw 4 m/s fails every seed at 150 m and retries at 170 m: all 10 criteria, 11.6 kW. +`high_wind_l_tether` 180 -> 170 m: Maasvlakte 11 m/s flies at 170 m with a starting margin of +0.83 (gate 0.82): all 10 criteria, 17.5 kW. Cabauw 8 m/s converges at 170 m and still fails +laps >= 2.5 (2.0 laps, 18.0 kW): its laps take 23.7-30.1 s against 12.2-19.8 s in the 2026-10-26 +scenario, so its patterns are larger, not its start longer. + +## 2026-10-31 — Angle-of-attack floor -7° (the ROM's validity) + +AWETrim `eeeefa8` (2026-10-28) bounds the trim angle of attack to 0-14° unless a request says +otherwise. At Cabauw 9 m/s (17.4 m/s at 100 m) the failed iterate sits on that 0° floor with the +depower on its 2.25 m ceiling and the trim residual infeasible; the recorded 180 m request +converges with the floor at -3, -6 or -10° (18.7 kW predicted, depower 2.0 m, alpha -0.5 to ++0.4°). `rom_config_aerostructural2` is valid from -7°, so new `box.angle_of_attack_min` -7.0, sent +with every `/init` as `angle_of_attack_limits`. Cabauw 10 m/s converges with it only without the +pattern box (alpha -0.9 to +0.1°, depower 2.08 m); dropping any single box row is not enough. +Flown: Cabauw 8 m/s at 170 m, all 10 criteria, 17.9 kW. Cabauw 9 m/s found no path at 170 m, +retried at 190 m and failed laps >= 2.5 (16.3 kW). Cabauw 10 m/s found none at 170 or 190 m. + +## 2026-11-01 — Cabauw 9 and 10 m/s: high-wind box 45° x 20° from 16.5 m/s aloft, 2.0 laps + +With the -7° floor, recorded requests replayed in-process: Cabauw 9 m/s (17.4 m/s at 100 m) at +180 m converges at a 21.5 m turn radius only without the box, and 10 m/s (19.3) only with the +elevation half-span cap 15 -> 20° together with the azimuth cap 36 -> 45° (or no elevation floor). +Under the 45°/20° box with the floor kept, 9 m/s converges at 170 and 180 m and 10 m/s at 180 m +only (none at 150-175 m). Dropping the floor gives 25 s instead of 28 s laps but puts the lowest +point at 33-35 m, under the 40 m `min_height` gate, so it stays. The high caps had equalled the +base ones, so their step moves 13.2 -> 16.5 m/s and only these two winds change; +`startup_retry_length_steps` [20] -> [10, 20] gives 10 m/s its 180 m. +Flown: both converge and track, but their 30-32 s laps from 170 and 180 m hold 2.0 laps, under +2.5; 150 and 160 m have no startup solution. New `run.min_laps_high` 2.0 from +`min_laps_high_wind` 16.5 m/s at `low_wind_height`. Cabauw 9 m/s: all 10 criteria, 15.0 kW, +power ratio 0.76, peak 6543 N. Cabauw 10 m/s: all 10 criteria, 15.2 kW, ratio 0.77, peak 6566 N. + +## 2026-11-01 — A cached startup seed uses up its retry + +Rerunning Cabauw 10 m/s sent seven new startup requests at 170 m, all 422 (~35 s each, ~4 min +per run): the seven from the first run were skipped as cached, but since 2026-09-19 a skip cost +no retry, so the budget was spent on seeds walked outward (31, 27, 33, 26, 34, 25, 35°) and the +next rerun would have walked further, out to ±10°. 170 m has no path at that wind (see the +2026-11-01 high-wind box entry), so the walk could only lose time. A cached seed now uses up its +place in `startup_retry_guess_sizes`/`startup_retry_el_offsets`: a rerun skips what failed +before and moves on to the next length at once. The outward walk (`startup_seed_offsets`) is +gone; widen the listed seeds to try more. diff --git a/docs/src/api/internals.md b/docs/src/api/internals.md index cca9e7f..86b6588 100644 --- a/docs/src/api/internals.md +++ b/docs/src/api/internals.md @@ -22,16 +22,21 @@ RobustKiteControllers.gate_candidate RobustKiteControllers.wants_challenge RobustKiteControllers.set_yaml_fields! RobustKiteControllers.depower_command! +RobustKiteControllers.AngleOfAttackLimits +RobustKiteControllers.aoa_limits RobustKiteControllers.depower_seed RobustKiteControllers.depower_spec RobustKiteControllers.set_depower_conversion! RobustKiteControllers.awetrim_depower_to_v3kite RobustKiteControllers.elevation_min_request RobustKiteControllers.merge_into! +RobustKiteControllers.min_laps_at RobustKiteControllers.min_turn_radius_request RobustKiteControllers.request_constraints RobustKiteControllers.request_depower_estimate RobustKiteControllers.setup_run +RobustKiteControllers.setup_with_startup +RobustKiteControllers.StartupNoPath RobustKiteControllers.solve_startup_path! RobustKiteControllers.startup_feasibility RobustKiteControllers.steering_command! diff --git a/docs/src/api/winch.md b/docs/src/api/winch.md index dbc38ac..a08b0b8 100644 --- a/docs/src/api/winch.md +++ b/docs/src/api/winch.md @@ -11,6 +11,7 @@ The parameters of the reel-out law that change with wind speed, from the project ```@docs winch_f_low +winch_kv winch_force_limit winch_table_lookup winch_table_select diff --git a/docs/src/examples_identification.md b/docs/src/examples_identification.md index 3cbee72..dfd6c56 100644 --- a/docs/src/examples_identification.md +++ b/docs/src/examples_identification.md @@ -165,3 +165,24 @@ cross-track error to a step of the attractor offset, at constant length and duri fitted with a second-order model and compared with the closed-loop model of `stability_opt_reelout.jl`. The measured part works on the saved tests in `data/steptest/` without flying. This analysis is work in progress. + +## Validation of the aerodynamics against AWETrim + +### [`validate_lift_drag.jl`](https://github.com/OpenSourceAWE/RobustKiteControllers.jl/blob/main/examples/validate_lift_drag.jl) +Compares V3Kite's lift and drag with AWETrim's reduced-order model at the same power-tape length +and apparent wind speed. The kite is parked at 150 m, and the ground wind is trimmed in each block +until the apparent wind speed reaches its target. AWETrim's `POST /aero_forces` is then queried at +the measured apparent wind speed, with the ROM every optimizer request uses (`AWETRIM_ROM`), at +the ROM's own trim. Drag is compared as the wing's plus that of all bridle lines. The results go to `output/lift_drag_validation.json`; `fly = false` re-queries AWETrim +without flying. About 3 minutes of simulation. + +### [`show_validation.jl`](https://github.com/OpenSourceAWE/RobustKiteControllers.jl/blob/main/examples/show_validation.jl) +Shows the saved comparison as an HTML page in the default browser: the ROM against V3Kite, with the +deviation in percent and the points outside the ROM's validity band marked, and V3Kite's drag split. + +### [`validate_masses.jl`](https://github.com/OpenSourceAWE/RobustKiteControllers.jl/blob/main/examples/validate_masses.jl) +Prints the masses of V3Kite's structure (for the project selected in `data/gui.yaml`) next to +those of AWETrim's system file: canopy, bridle (lines and point masses), KCU, the airborne +total, the wing mass AWETrim's trim actually uses, and the tether mass per metre. Builds the +structure without compiling a model and reads AWETrim's file with AWETrim's own Python, so it +needs neither a running server nor a flight. diff --git a/examples/build_all_scenarios.jl b/examples/build_all_scenarios.jl index cc15ff9..3c27c3d 100644 --- a/examples/build_all_scenarios.jl +++ b/examples/build_all_scenarios.jl @@ -9,7 +9,8 @@ replace each scenario folder with the new run. For each site the wind speeds are read from the run summaries of its scenario folders; a `_2`, `_3`, ... repeat shares its wind speed with the unsuffixed folder, so each wind speed is flown once and replaces the unsuffixed folder -only (the repeats are kept as they are). Each run is +only (the repeats are kept as they are). A wind speed in `SKIP_WINDS` is not +flown at all and its old scenario is kept. Each run is `simple_opt_reelout.jl` without plots, at the project and wind speed set in `data/gui.yaml`, and is moved in by `move_scenario.jl`, but only if it finished cleanly and passed all success criteria: otherwise the old scenario is kept and @@ -43,6 +44,11 @@ const SITE_PROJECTS = [ "maasvlakte" => "system_reelout_maasvlakte.yaml", "cabauw" => "system_reelout_cabauw.yaml", ] +"Wind speeds [m/s] not flown at a site, e.g. because their scenario is already up to date" +const SKIP_WINDS = Dict( + "maasvlakte" => [], + "cabauw" => Float64[], +) output_dir = normpath(joinpath(@__DIR__, "..", "output")) run_done_file = joinpath(output_dir, "last_run_done.txt") progress_file = joinpath(output_dir, "build_all_scenarios.txt") @@ -74,6 +80,10 @@ try for (site, project) in SITE_PROJECTS set_selected_project(project) for wind in scenario_winds(site) + if wind in get(SKIP_WINDS, site, Float64[]) + log_line("$site $wind m/s: skipped (SKIP_WINDS), the old scenario is kept") + continue + end write_gui_field("wind_speed", wind) rm(run_done_file; force = true) t0 = time() diff --git a/examples/menu2.jl b/examples/menu2.jl index 626d200..6956f4a 100644 --- a/examples/menu2.jl +++ b/examples/menu2.jl @@ -41,6 +41,9 @@ const IDENTIFICATION_SCRIPTS = [ "identify_depower_factor.jl - growth of the response time with depower (6 min!)" => "identify_depower_factor.jl", "identify_kite_correction.jl - measured kite correction by multisine injection (5 min!)" => "identify_kite_correction.jl", "stability_opt_reelout.jl - disk margins of the reel-out course loop with the identified model" => "stability_opt_reelout.jl", + "validate_lift_drag.jl - lift and drag of V3Kite vs. the AWETrim ROM (3 min!)" => "validate_lift_drag.jl", + "show_validation.jl - show the lift/drag validation in the browser" => "show_validation.jl", + "validate_masses.jl - masses of V3Kite vs. AWETrim's model" => "validate_masses.jl", ] """ diff --git a/examples/reelout_results.jl b/examples/reelout_results.jl index 15d3bb6..3e59ebb 100644 --- a/examples/reelout_results.jl +++ b/examples/reelout_results.jl @@ -57,6 +57,7 @@ function score_log(setup, st::RunState) min_elevation = fcs.run.min_elevation, az_center = az_c_log, az_amplitude = az_amp_log, el_height = el_h_log, min_span_frac = fcs.run.min_span_frac, require_final = true, + min_laps = RobustKiteControllers.min_laps_at(fcs, project_set), max_force = project_set.max_force, cross_track = d_raw_log, # A parking kite leaves the path on purpose: the pattern ends where it parks. t_end = something(RobustKiteControllers.park_start_time(fcs, sl), Inf)) @@ -304,10 +305,8 @@ function traj_opt_block(setup, st::RunState, power_block, feasibility, scored) guess_el_center_high at and above guess_el_center_wind_ref, plus \ the retry offset below [deg]"), "el_center_retry_offset_deg" => (startup_seed_offset, - "offset of the seed the startup solve converged from after a 422 — a \ - startup_retry_el_offsets entry, or a whole degree walked outward past \ - them when the failure cache rejected the listed ones; 0 means the \ - shipped guess converged [deg]"), + "offset of the seed the startup solve converged from after a 422, a \ + startup_retry_el_offsets entry; 0 means the shipped guess converged [deg]"), "points" => (GUESS_POINTS, "points the guess was sent with"), "depower_seed_m" => (round(depower_seed(tos, inflow.wind_speed); digits = 3), "power-tape length the solve started from, input_depower ramped with \ diff --git a/examples/show_validation.jl b/examples/show_validation.jl new file mode 100644 index 0000000..9b7c856 --- /dev/null +++ b/examples/show_validation.jl @@ -0,0 +1,175 @@ +# Copyright (c) 2026 Uwe Fechner +# SPDX-License-Identifier: MPL-2.0 + +""" +Show the lift/drag comparison saved by `validate_lift_drag.jl` +(`output/lift_drag_validation.json`) as an HTML page in the default browser: one table +per AWETrim ROM, each row a V3Kite block (power tape, measured apparent wind speed), +with V3Kite's lift and drag next to the ROM's at its own trim, and the deviation of the +ROM from V3Kite in percent. Rows outside the ROM's validity band are marked. + + include("examples/show_validation.jl") +""" + +using Pkg +if Base.active_project() != joinpath(@__DIR__, "Project.toml") + Pkg.activate(joinpath(@__DIR__)) +end + +using JSON3 +using Printf + +"The results `validate_lift_drag.jl` writes" +const RESULT_FILE = normpath(joinpath(@__DIR__, "..", "output", "lift_drag_validation.json")) +# A snap-confined browser cannot read dotdirs under $HOME, so the page goes to $HOME itself. +"The page written and opened" +const HTML_FILE = joinpath(homedir(), "lift_drag_validation.html") + +const CSS = """ +:root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --line:#d9d9de; --head:#f3f3f6; + --good:#1a7f37; --warn:#b26a00; --bad:#c62828; } +@media (prefers-color-scheme: dark) { + :root { --bg:#16161a; --fg:#ececf1; --muted:#9a9aa3; --line:#34343c; --head:#202027; + --good:#4cc26a; --warn:#e0a03a; --bad:#ef6b6b; } } +body { background:var(--bg); color:var(--fg); margin:0 auto; max-width:1100px; padding:24px 16px; + font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } +h1 { font-size:22px; margin:0 0 4px; } h2 { font-size:17px; margin:28px 0 8px; } +.meta { color:var(--muted); font-size:13px; } +.wrap { overflow-x:auto; } +table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; } +th, td { padding:6px 10px; border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; } +thead th { background:var(--head); font-weight:600; } +thead tr:first-child th { text-align:center; } +th.grp { border-left:2px solid var(--line); } td.grp { border-left:2px solid var(--line); } +.good { color:var(--good); } .warn { color:var(--warn); } .bad { color:var(--bad); font-weight:600; } +.out { color:var(--bad); } +ul { color:var(--muted); font-size:13px; padding-left:18px; } +""" + +esc(x) = replace(string(x), "&" => "&", "<" => "<", ">" => ">") +num(x, d = 0) = x isa Number ? @sprintf("%.*f", d, x) : "–" +ld(r) = haskey(r, "error") ? "error" : + "$(num(r["lift"])) / $(num(r["drag"]))" + +"Deviation of `x` from the V3Kite value `ref` in percent, coloured by size" +function deviation(x, ref) + (x isa Number && ref isa Number && ref != 0) || return "–" + d = 100 * (x - ref) / ref + cls = abs(d) < 5 ? "good" : abs(d) < 15 ? "warn" : "bad" + return "$(@sprintf("%+.0f", d)) %" +end + +""" + rom_table(io, label, v3kite, awetrim) + +One table: the V3Kite blocks against the ROM `label`. +""" +function rom_table(io, label, v3kite, awetrim) + first_ok = something(findfirst(aw -> !haskey(aw[label]["trim"], "error"), awetrim), 0) + rom_file = first_ok == 0 ? "" : basename(string(awetrim[first_ok][label]["trim"]["rom_config"])) + println(io, "

ROM: $(esc(label)) $(esc(rom_file))

") + println(io, """
+ + + + + + + """) + for (res, aw) in zip(v3kite, awetrim) + trim = aw[label]["trim"] + valid = get(trim, "within_validity", nothing) + band = get(trim, "input_depower_validity", nothing) + valid_cell = valid === nothing ? "–" : valid ? "yes" : + "no" + println(io, "", + "", + "", + "", + "", + "") + end + println(io, "
V3KiteAWETrim, ROM trim
tape [m]va [m/s]AoA [°]L / D [N]AoA [°]L / D [N]ΔLΔDin ROM band
$(num(res["tape"], 2))$(num(res["v_app"], 1))$(num(res["aoa"], 2))$(num(res["lift"])) / $(num(res["drag"]))$(num(get(trim, "angle_of_attack", nothing), 2))$(ld(trim))$(deviation(get(trim, "lift", nothing), res["lift"]))$(deviation(get(trim, "drag", nothing), res["drag"]))$valid_cell
") +end + +""" + drag_table(io, v3kite) + +The V3Kite drag split per block, along the wing's apparent wind; skipped for results +saved before the split was recorded. +""" +function drag_table(io, v3kite) + all(res -> haskey(res, "drag_vsm"), v3kite) || return + println(io, """

V3Kite drag split

+ + + + """) + for res in v3kite + println(io, "", + "", + "", + "", + "") + end + println(io, "
tape [m]va [m/s]VSM wing [N]bridle lines [N]compared D [N]bridle in lift_drag [N]lift_drag D [N]tether [N]KCU [N]
$(num(res["tape"], 2))$(num(res["v_app"], 1))$(num(res["drag_vsm"]))$(num(res["drag_bridle"]))$(num(res["drag"]))$(num(res["drag_bridle_in_wing"]))$(num(res["drag_lift_drag"]))$(num(res["drag_tether"]))$(num(res["drag_kcu"]))
") +end + +""" + write_validation_html(data, file) + +Write the page for the parsed `lift_drag_validation.json` `data` to `file`. +""" +function write_validation_html(data, file) + flown = data["flown"] + git = flown["git"] + open(file, "w") do io + println(io, """ + + Lift/Drag Validation +

Lift and drag: V3Kite vs. AWETrim ROM

+

Flown $(esc(flown["date"])) (RobustKiteControllers $(esc(git["hash"])) + $(esc(git["status"]))), AWETrim queried $(esc(data["queried"])) at $(esc(data["url"])). + Parked at $(num(flown["park"]["tether_length"])) m, $(esc(flown["park"]["project"])), + averaged over $(num(flown["t_avg"])) s per point.

+

Not a valid comparison: the two models are not in the same trim + condition. V3Kite is parked and carries its weight, the KCU's and the tether's; the + ROM is at its gravity-free trim at the centre of the wind window.

""") + for label in data["roms"] + rom_table(io, label, data["v3kite"], data["awetrim"]) + end + drag_table(io, data["v3kite"]) + println(io, """""") + end + return file +end + +""" + open_in_browser(file) + +Open `file` in the default browser on Linux, Windows or macOS; print its path if that fails. +""" +function open_in_browser(file) + cmd = Sys.iswindows() ? `cmd /c start "" $file` : Sys.isapple() ? `open $file` : `xdg-open $file` + try + run(pipeline(cmd; stdout = devnull, stderr = devnull); wait = false) + catch + println("Could not open a browser; open $file manually.") + end + nothing +end + +isfile(RESULT_FILE) || error("No $RESULT_FILE; run examples/validate_lift_drag.jl first.") +data = JSON3.read(read(RESULT_FILE, String), Dict{String, Any}) +@info "show_validation: wrote $(write_validation_html(data, HTML_FILE))" +open_in_browser(HTML_FILE) +nothing diff --git a/examples/simple_opt_fig8.jl b/examples/simple_opt_fig8.jl index 85eff9e..330ffcd 100644 --- a/examples/simple_opt_fig8.jl +++ b/examples/simple_opt_fig8.jl @@ -96,7 +96,8 @@ include(joinpath(@__DIR__, "model_setup.jl")) # The optimizer client, part of the package but not exported (src/awetrim_client.jl), and # `HTTP.StatusError` for a 422. using HTTP -using RobustKiteControllers: OPT_NAME, GUESS_POINTS, RESAMPLE_POINTS, REG_WEIGHT +using RobustKiteControllers: OPT_NAME, GUESS_POINTS, RESAMPLE_POINTS, REG_WEIGHT, awetrim_system_config, + aoa_limits using RobustKiteControllers: Trajectory, InitParams, StepParams, ensure_server, opt_init, opt_step, opt_trajectory, inflow_from_settings, winch_from_wc, cap_wind_speed, depower_seed, elevation_min_request, min_turn_radius_request, pattern_limits_from, @@ -227,7 +228,9 @@ opt_reply = opt_init(InitParams(; name = OPT_NAME, length = l0, input_depower = depower_seed(tos, inflow.wind_speed), reg_weight = REG_WEIGHT, min_turn_radius = opt_r_min, - pattern_limits = opt_box); + pattern_limits = opt_box, + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)); url = tos.server.base_url) # No automatic retry with a different guess, deliberately: a guess that merely # converges is not the same answer, and picking one silently would hide which diff --git a/examples/simple_opt_reelout.jl b/examples/simple_opt_reelout.jl index 1a2f046..b8a8709 100644 --- a/examples/simple_opt_reelout.jl +++ b/examples/simple_opt_reelout.jl @@ -101,9 +101,10 @@ flown here; the pattern's centre and extent are measured off the installed path. The run keeps two: `setup` (see `setup_run`), everything it reads, and `st`, the `RunState` everything it writes. The setup and the startup are the package's -(`src/run_setup.jl`, `src/startup_path.jl`): the script hands `setup_run` its -`init_model` (`model_setup.jl`), since the package does not depend on the model, -and calls the startup steps in order. `startup_feasibility` (the gates of +(`src/run_setup.jl`, `src/startup_path.jl`): the script hands `setup_with_startup` +its `init_model` (`model_setup.jl`), since the package does not depend on the model, +which sets the run up again at a longer tether when the startup finds no path +(`startup_retry_length_steps`), and calls the remaining startup steps in order. `startup_feasibility` (the gates of [`check_startup_path`](@ref)) adds `feas`, `margin5`, `c1_at_phase` and `phase5_margin_at` to `setup`. Left here are the loop around the model's `step!` (`run_loop!`) and `reelout_results.jl` (scoring, summary YAML, archive, plots, @@ -129,7 +130,7 @@ using V3Kite using RobustKiteControllers using RobustKiteControllers: project_file # V3Kite exports a project_file(project, entry) of its own # The run's setup and startup (src/run_setup.jl, src/startup_path.jl), not exported. -using RobustKiteControllers: setup_run, write_run_done, solve_startup_path!, log_startup_reply, +using RobustKiteControllers: setup_with_startup, write_run_done, log_startup_reply, log_lobe_lift, adopt_startup_path!, finish_startup!, capture_startup_geometry!, startup_feasibility, init_phase5_and_controller!, init_loop_state!, merge_into! import WinchControllers # module name, for the wc_overrides refresh (calc_vro) @@ -156,20 +157,14 @@ toc("Loaded packages in: ") set_data_path(normpath(joinpath(@__DIR__, "..", "data"))) # V3Kite is torque-only; the winch adapter uses WinchControllers.jl to implement the force dependent speed control. include(joinpath(@__DIR__, "winch_adapter.jl")) -# The model, which the package cannot build: `init_model`, passed to `setup_run`. +# The model, which the package cannot build: `init_model`, passed to `setup_with_startup`. include(joinpath(@__DIR__, "model_setup.jl")) # Reference curve and log name for simple_reelout_plots.jl; set by reelout_results.jl, cleared here. REF_PATH = nothing LOG_NAME = nothing # `run_example(file; kwargs)` includes this script; `script_inputs` reads its kwargs, else the defaults. -setup = setup_run(script_inputs(@__FILE__, run_input_defaults()); init_model) - -# What phases 3+ fly: the optimizer's depower; the fixed setpoint until the first optimizer answer. -st = RunState(; l_set = setup.l_set, opt_r_scale = setup.opt_r_scale, opt_r_min = setup.opt_r_min, - depower_flown_opt = setup.fcs.course.depower_setpoint) - -merge_into!(setup, solve_startup_path!(setup, st)) +setup, st = setup_with_startup(script_inputs(@__FILE__, run_input_defaults()); init_model) toc("Received the optimized path in: ") log_startup_reply(setup.fcs, setup.tos, st.opt_result, setup.opt_r_min) log_lobe_lift(setup.fcs, st.opt_result) diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl new file mode 100644 index 0000000..e820a8e --- /dev/null +++ b/examples/validate_lift_drag.jl @@ -0,0 +1,262 @@ +# Copyright (c) 2026 Uwe Fechner +# SPDX-License-Identifier: MPL-2.0 + +""" +Compare the lift and drag of the V3Kite model with AWETrim's reduced-order model (ROM) +at the same power-tape length and apparent wind speed, and save the result to +`output/lift_drag_validation.json` for `show_validation.jl`. + +V3Kite side: the kite is parked at 150 m as in `simple_auto_parking.jl` (heading PID, +winch length loop) and flies one block per point of `points`, `(tape, v_a)` with the +power-tape length in m and the target apparent wind speed in m/s. In each block the +ground wind is first trimmed for `t_trim` seconds until `v_a` reaches the target (rate +limited, so the kite stays parked), then held for `t_avg` seconds, over which lift, wing +drag, `span_mean_aoa` and `v_a` are averaged. Lift is V3Kite's `lift_drag`: the wing's +force perpendicular to its apparent wind. Drag is put on AWETrim's basis, the VSM wing drag +plus the drag of ALL bridle lines (`drag_parts`); `lift_drag`'s own drag, which +holds only the bridle drag lumped onto the wing nodes, and the tether and KCU drag are +saved next to it. + +AWETrim side: `POST /aero_forces` of the AWETrim server at `url` (needs AWETrim with that +endpoint; restart the server after switching branch), at the MEASURED mean `v_a` of each +block, for the ROM `AWETRIM_ROM` (`src/awetrim_client.jl`), at the ROM's own +gravity-free trim. + +`fly = false` re-queries AWETrim for the V3Kite results already saved, without flying: + + include("examples/validate_lift_drag.jl") + run_example("validate_lift_drag.jl"; fly = false) + run_example("validate_lift_drag.jl"; points = [(1.6, 12.0)], url = "http://127.0.0.1:8001") +""" + +using Pkg +if Base.active_project() != joinpath(@__DIR__, "Project.toml") + Pkg.activate(joinpath(@__DIR__)) +end + +using RobustKiteControllers +using RobustKiteControllers: run_example, script_inputs, package_git_state, AWETRIM_URL, + AWETRIM_ROM +using V3Kite +using V3Kite: init, step! +using DiscretePIDs: set_K! +using HTTP, JSON3 +using OrderedCollections: OrderedDict +using LinearAlgebra: dot, norm, normalize +import SymbolicAWEModels +using Statistics +using Printf +import Dates +include(joinpath(@__DIR__, "winch_adapter.jl")) # winch_torque!: V3Kite is torque-only + +@info "validate_lift_drag.jl: lift and drag of the parked V3Kite vs. AWETrim's ROM at the \ + same power tape and apparent wind speed, saved for show_validation.jl." + +"Where the results are saved" +const RESULT_FILE = normpath(joinpath(@__DIR__, "..", "output", "lift_drag_validation.json")) +"The ROMs queried: label => `rom_config_path`; only the one every optimizer request uses" +const ROMS = [splitext(AWETRIM_ROM)[1] => AWETRIM_ROM] +"The parked setup of `simple_auto_parking.jl`, so its settled state is reused" +const PARK = (project = "system_reelout.yaml", v_wind = 9.51, tether_length = 150.0, + depower = 0.25, dt = 0.05 / 3, body_damping = [0.0, 0.0, 40.0], + damping_per_stiffness = 0.002) +"Heading PID of `simple_auto_parking.jl`, gain scheduled with 1/v_a" +const HEADING = (P = 1.1, I = false, D = 0.15, v_app_ref = 13.1, v_app_min = 5.0, + max_steering = 0.175) +"Wind trim: gain [1/s], max rate [m/s²] and the time constant of the v_a filter [s]" +const WIND_TRIM = (gain = 0.4, max_rate = 0.5, tau = 2.0) + +(; points, t_trim, t_avg, fly, url) = script_inputs(@__FILE__, + (; points = [(1.60, 15.3), (1.60, 8.8), (1.76, 15.3), (1.76, 8.7)], + t_trim = 30.0, t_avg = 10.0, fly = true, url = AWETRIM_URL)) + +""" + check_aero_forces_endpoint(url) + +Fail before flying when the AWETrim server at `url` does not answer or has no +`/aero_forces` (an older AWETrim, or a server started before the branch switch). +""" +function check_aero_forces_endpoint(url) + spec = try + String(HTTP.get(url * "/openapi.json"; request_timeout = 5).body) + catch err + error("No AWETrim server at $url ($(typeof(err))); start it with bin/run_server start.") + end + occursin("/aero_forces", spec) || + error("The AWETrim server at $url has no /aero_forces: check out AWETrim's branch \ + feat/server-aero-forces (until it is merged) and restart the server \ + (bin/run_server restart).") + nothing +end + +""" + drag_parts(s) -> NamedTuple + +Drag [N] along the wing's apparent wind, split as AWETrim needs it: `vsm` (the VSM wing), +`bridle` (all bridle lines), `bridle_in_wing` (the part of it `lift_drag` counts as wing +drag: SymbolicAWEModels lumps half of each segment's drag onto each end point, and +`lift_drag` adds the wing nodes' share), `tether` and `kcu` (the points with an area). +Segment drag is recomputed with SymbolicAWEModels' law at the mean wind and velocity of +the two end points, so it matches the model to about 2 %. +""" +function drag_parts(s) + sys = s.sys + wing = sys.wings[1] + va_b = wing.va_b + va_hat = V3Kite.calc_R_b_w(sys) * (va_b / norm(va_b)) + tether_segs = Set(Iterators.flatten(t.segment_idxs for t in sys.tethers)) + bridle = bridle_in_wing = tether = 0.0 + for seg in sys.segments + a, b = sys.points[seg.point_idxs[1]], sys.points[seg.point_idxs[2]] + a.is_wing_node && b.is_wing_node && continue # wing frame: no line drag + u = normalize(b.pos_w - a.pos_w) + va = 0.5 * (a.wind_vec + b.wind_vec - a.vel_w - b.vel_w) + rho = SymbolicAWEModels.air_density(s.sam.am, 0.5 * (a.pos_w[3] + b.pos_w[3])) + f = 0.5 * rho * s.set.cd_tether * norm(va) * seg.len * seg.diameter * + (va - dot(va, u) * u) + d = dot(f, va_hat) + if seg.idx in tether_segs + tether += d + else + bridle += d + bridle_in_wing += 0.5 * d * (a.is_wing_node + b.is_wing_node) + end + end + kcu = sum(sys.points) do p + p.area > 0 || return 0.0 + rho = SymbolicAWEModels.air_density(s.sam.am, p.pos_w[3]) + dot(SymbolicAWEModels.point_drag_force(p.wind_vec - p.vel_w, rho, p.drag_coeff, p.area), + va_hat) + end + return (; vsm = dot(wing.aero_force_b, va_b / norm(va_b)), bridle, bridle_in_wing, tether, kcu) +end + +""" + fly_points(points; t_trim, t_avg) -> Vector{OrderedDict} + +Fly one parked block per `(tape, v_a)` point in a single V3Kite run and return the +block means (forces in N, angles in degrees). `drag` is on AWETrim's basis (VSM wing + +all bridle lines), `drag_lift_drag` is V3Kite's `lift_drag` drag. +""" +function fly_points(points; t_trim, t_avg) + s = init(PARK.v_wind, PARK.tether_length; body_start_damping = PARK.body_damping, + body_sim_damping = 0.8 .* PARK.body_damping, + damping_per_stiffness = PARK.damping_per_stiffness, + depower_setpoint = PARK.depower, dt = PARK.dt, + sim_time = length(points) * (t_trim + t_avg) + 1.0, + system_yaml = PARK.project, use_turbulence = 0.0, remake_model = false) + l0 = s.sys_state.l_tether[1] + wpc = WinchPosController(WCSettings(; dt = s.dt); dt = s.dt) + pid = create_heading_pid(; K = HEADING.P, Ti = HEADING.I, Td = HEADING.D, dt = s.dt, + umin = -HEADING.max_steering, umax = HEADING.max_steering) + v_wind = PARK.v_wind + v_app_f = s.sys_state.v_app + n_trim, n_avg = round(Int, t_trim / s.dt), round(Int, t_avg / s.dt) + results = OrderedDict{String, Any}[] + for (tape, v_target) in points + rel_depower = depower_length_to_percentage(tape) / 100 + acc = Dict(k => Float64[] for k in (:v_app, :lift, :drag, :drag_lift_drag, :drag_vsm, + :drag_bridle, :drag_bridle_in_wing, :drag_tether, + :drag_kcu, :aoa, :elevation, :force, :v_wind)) + for i in 1:(n_trim + n_avg) + v_app = s.sys_state.v_app + v_app_f += s.dt / WIND_TRIM.tau * (v_app - v_app_f) + if i <= n_trim + dv = WIND_TRIM.gain * v_wind * (v_target / v_app_f - 1) + v_wind += s.dt * clamp(dv, -WIND_TRIM.max_rate, WIND_TRIM.max_rate) + end + set_K!(pid, HEADING.P * HEADING.v_app_ref / max(v_app, HEADING.v_app_min), 0.0, + s.sys_state.heading) + rel_steering = pid(0.0, s.sys_state.heading, 0.0) + step!(s; rel_depower, rel_steering, v_wind_gnd = v_wind, + set_torque = winch_torque!(wpc, s, l0)) + i <= n_trim && continue + lift, drag = lift_drag(s) + parts = drag_parts(s) + push!(acc[:v_app], s.sys_state.v_app) + push!(acc[:lift], lift) + push!(acc[:drag], parts.vsm + parts.bridle) + push!(acc[:drag_lift_drag], drag) + for (k, v) in pairs(parts) + push!(acc[Symbol(:drag_, k)], v) + end + push!(acc[:aoa], rad2deg(span_mean_aoa(s.sys))) + push!(acc[:elevation], rad2deg(s.sys_state.elevation)) + push!(acc[:force], winch_force(s)) + push!(acc[:v_wind], v_wind) + end + res = OrderedDict{String, Any}("tape" => tape, "v_app_target" => v_target, + "rel_depower" => rel_depower) + for (k, v) in acc + res[string(k)] = mean(v) + end + res["lift_std"] = std(acc[:lift]) + res["v_app_std"] = std(acc[:v_app]) + @printf("V3Kite tape %.2f m v_a %5.2f m/s (target %5.2f) L %6.0f N D %5.0f N (lift_drag %5.0f N) AoA %5.2f° wind %5.2f m/s\n", + tape, res["v_app"], v_target, res["lift"], res["drag"], res["drag_lift_drag"], + res["aoa"], res["v_wind"]) + push!(results, res) + end + return results +end + +""" + aero_forces(url, tape, v_app; rom = nothing) -> Dict + +One `POST /aero_forces` at the ROM's trim. A request the server rejects returns +`Dict("error" => detail)` instead of throwing. +""" +function aero_forces(url, tape, v_app; rom = nothing) + req = Dict{String, Any}("input_depower" => tape, "speed_apparent_wind" => v_app) + isnothing(rom) || (req["rom_config_path"] = rom) + try + resp = HTTP.post(url * "/aero_forces", ["Content-Type" => "application/json"]; + body = JSON3.write(req)) + return JSON3.read(resp.body, Dict{String, Any}) + catch err + err isa HTTP.StatusError || rethrow() + return Dict{String, Any}("error" => String(copy(err.response.body))) + end +end + +""" + query_awetrim(v3kite, url) -> Vector{OrderedDict} + +For each V3Kite block: the ROM forces at its tape and mean `v_a`, per ROM of `ROMS`, +at the ROM's trim (`trim`). +""" +function query_awetrim(v3kite, url) + map(v3kite) do res + OrderedDict{String, Any}(label => OrderedDict{String, Any}( + "trim" => aero_forces(url, res["tape"], res["v_app"]; rom)) + for (label, rom) in ROMS) + end +end + +check_aero_forces_endpoint(url) +if fly + v3kite = fly_points(points; t_trim, t_avg) + flown = Dict("date" => string(Dates.now()), "git" => package_git_state(), + "park" => PARK, "t_trim" => t_trim, "t_avg" => t_avg) +else + isfile(RESULT_FILE) || error("No $RESULT_FILE to re-query; run with fly = true first.") + saved = JSON3.read(read(RESULT_FILE, String), Dict{String, Any}) + v3kite, flown = saved["v3kite"], saved["flown"] +end +awetrim = query_awetrim(v3kite, url) + +for (res, aw) in zip(v3kite, awetrim), (label, _) in ROMS + trim = aw[label]["trim"] + fmt(r) = haskey(r, "error") ? " error" : @sprintf("%5.0f / %4.0f", r["lift"], r["drag"]) + @printf("AWETrim %-16s tape %.2f m v_a %5.2f m/s trim %s N (%5.2f°)\n", + label, res["tape"], res["v_app"], fmt(trim), get(trim, "angle_of_attack", NaN)) +end + +mkpath(dirname(RESULT_FILE)) +open(RESULT_FILE, "w") do io + JSON3.pretty(io, OrderedDict("flown" => flown, "queried" => string(Dates.now()), + "url" => url, "roms" => first.(ROMS), + "v3kite" => v3kite, "awetrim" => awetrim)) +end +@info "validate_lift_drag: wrote $RESULT_FILE; show it with include(\"examples/show_validation.jl\")." +nothing diff --git a/examples/validate_masses.jl b/examples/validate_masses.jl new file mode 100644 index 0000000..93eb405 --- /dev/null +++ b/examples/validate_masses.jl @@ -0,0 +1,120 @@ +# Copyright (c) 2026 Uwe Fechner +# SPDX-License-Identifier: MPL-2.0 + +""" +Compare the masses of the V3Kite model with those of AWETrim's quasi-steady model and +print them side by side: canopy, bridle, KCU, the airborne total and the tether mass per +metre. + +V3Kite side: the `SystemStructure` of `project` (default: the project selected in +`data/gui.yaml`), built as `init` builds it before settling, without compiling a model. +The canopy is the `extra_mass` spread over the wing points, the KCU that of point 1. The +bridle is its line segments (density × cross-section × `l0`, as SymbolicAWEModels +computes it) plus the `extra_mass` of the remaining points (pulleys). + +AWETrim side: the system file the runs send, `awetrim_system_config()` (AWETrim's own +file with the bridle mass set to `AWETRIM_BRIDLE_MASS`; `system_config` overrides it), +read with AWETrim's own Python in `awetrim_dir`. `canopy`, `bridle` and `kcu` are the +file's components; `wing in trim` is the point mass AWETrim's trim actually uses, which +includes the bridle since AWETrim `aa25dfc` (2026-10-29) and is the canopy alone before. + + include("examples/validate_masses.jl") + run_example("validate_masses.jl"; project = "system_reelout_cabauw.yaml") +""" + +using Pkg +if Base.active_project() != joinpath(@__DIR__, "Project.toml") + Pkg.activate(joinpath(@__DIR__)) +end + +using RobustKiteControllers: run_example, script_inputs, project_file, read_gui_field, + awetrim_system_config, AWETRIM_DIR +using V3Kite +using V3Kite: V3SettleConfig, build_settling_struct, struc_geometry_path, + aero_geometry_path, load_kite, project_data_path, resolve_aero_mode +import SymbolicAWEModels +import JSON3 +using Printf + +(; project, awetrim_dir, system_config) = script_inputs(@__FILE__, + (; project = something(read_gui_field("project"), "system_reelout_maasvlakte.yaml"), + awetrim_dir = AWETRIM_DIR, system_config = awetrim_system_config())) + +"Python run in AWETrim's venv: the masses of a system file as JSON on the last line" +const AWETRIM_MASSES_PY = """ +import json, sys, warnings +warnings.simplefilter("ignore") +from awetrim.utils.config_paths import LEI_V3_SYSTEM_CONFIG +from awetrim.utils.system_config import get_kite, get_kcu_mass +from awetrim.system import factory +path = sys.argv[1] if len(sys.argv) > 1 else str(LEI_V3_SYSTEM_CONFIG) +cfg = factory._load_yaml(factory.Path(path)) +kite = get_kite(cfg) +bridle = ((kite.get("bridle") or {}).get("structure") or {}).get("mass", 0.0) or 0.0 +mass_wing, _, mass_kcu, _, _, d, rho, _, _ = factory._extract_params_awesio(cfg) +print(json.dumps(dict(system=path, canopy=kite["wing"]["structure"]["mass"], + bridle=float(bridle), kcu=float(get_kcu_mass(cfg) or 0.0), wing_in_trim=mass_wing, + kcu_in_trim=mass_kcu, tether_diameter=d, tether_density=rho))) +""" + +""" + v3kite_masses(project) -> NamedTuple + +Masses [kg] of V3Kite's structure for `project`: `canopy`, `bridle_lines`, +`bridle_points`, `kcu`, and the tether's `tether_per_m` [kg/m]. +""" +function v3kite_masses(project) + path = project_file(project) + data_path = project_data_path(path, nothing) + kite_set = load_kite(path; data_path) + config = V3SettleConfig(project = path, kite_set = kite_set, v_wind = 7.0, + tether_length = 150.0) + sys, _ = build_settling_struct(config; data_path, + source_struc = struc_geometry_path(path; data_path), + source_aero = aero_geometry_path(path; data_path, + aero_mode = resolve_aero_mode(kite_set))) + seg_mass(s) = 2 * SymbolicAWEModels.segment_half_mass(s.l0, s.diameter, s.density) + tether_segs = Set(Iterators.flatten(t.segment_idxs for t in sys.tethers)) + tether = [s for s in sys.segments if s.idx in tether_segs] + wing_pts = Set(Iterators.flatten(st.point_idxs for st in sys.stations)) + return (canopy = sum(sys.points[i].extra_mass for i in wing_pts), + bridle_lines = sum(seg_mass(s) for s in sys.segments if !(s.idx in tether_segs)), + bridle_points = sum(p.extra_mass for (i, p) in enumerate(sys.points) + if i != 1 && !(i in wing_pts); init = 0.0), + kcu = sys.points[1].extra_mass, + tether_per_m = sum(seg_mass, tether) / sum(s.l0 for s in tether)) +end + +""" + awetrim_masses(awetrim_dir, system_config) -> JSON3.Object + +Masses of AWETrim's system file, from `AWETRIM_MASSES_PY` run in `awetrim_dir/venv`. +""" +function awetrim_masses(awetrim_dir, system_config) + python = joinpath(awetrim_dir, "venv", "bin", "python") + isfile(python) || error("No AWETrim venv at $python; run bin/install_awetrim.") + args = isnothing(system_config) ? String[] : [system_config] + out = read(Cmd(`$python -c $AWETRIM_MASSES_PY $args`; dir = awetrim_dir), String) + return JSON3.read(last(split(strip(out), '\n'))) +end + +v3 = v3kite_masses(project) +at = awetrim_masses(awetrim_dir, system_config) +v3_bridle = v3.bridle_lines + v3.bridle_points +v3_above = v3.canopy + v3_bridle +at_tether_per_m = at.tether_density * π * at.tether_diameter^2 / 4 + +println("\nV3Kite: $project") +println("AWETrim: $(at.system)\n") +@printf("%-26s %10s %10s %10s\n", "mass [kg]", "V3Kite", "AWETrim", "diff") +row(name, a, b) = @printf("%-26s %10.3f %10.3f %+10.3f\n", name, a, b, b - a) +row("canopy", v3.canopy, at.canopy) +@printf("%-26s %10.3f\n", " bridle lines", v3.bridle_lines) +@printf("%-26s %10.3f\n", " bridle point masses", v3.bridle_points) +row("bridle", v3_bridle, at.bridle) +row("KCU", v3.kcu, at.kcu) +row("airborne (file)", v3_above + v3.kcu, at.canopy + at.bridle + at.kcu) +row("wing in trim", v3_above, at.wing_in_trim) +row("airborne in trim", v3_above + v3.kcu, at.wing_in_trim + at.kcu_in_trim) +row("tether [kg/m]", v3.tether_per_m, at_tether_per_m) +nothing diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index 1d3c6a3..c86ff10 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -1,13 +1,13 @@ | date | time | v_wind | power_ratio | total_time | rt_factor | opt_requests | opts_installed | av_power | min_power | max_power | min_force | av_force | max_force | v_ro_min | v_ro_av | v_ro_max | av_depower | rms_error | success_criteria | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| 2026-10-26 | 21:03:58 | 3.0 | 0.71 | 195.1 | 5.3 | 4 | 4 | 2037 | 1462 | 3230 | 1082 | 1349 | 1946 | 1.35 | 1.5 | 1.72 | 0.286 | 0.6 | all 10 passed | -| 2026-10-26 | 21:07:14 | 4.0 | 0.71 | 185.3 | 5.96 | 5 | 5 | 5258 | 3915 | 6623 | 1985 | 2563 | 3072 | 1.32 | 2.05 | 2.2 | 0.286 | 0.64 | all 10 passed | -| 2026-10-26 | 21:09:38 | 5.0 | 0.72 | 67.0 | 5.94 | 5 | 5 | 10588 | 8318 | 12159 | 3502 | 4089 | 4530 | 2.01 | 2.59 | 2.72 | 0.286 | 0.76 | all 10 passed | -| 2026-10-26 | 21:10:53 | 5.5 | 0.71 | 69.0 | 5.8 | 5 | 5 | 14149 | 11302 | 15699 | 4259 | 4937 | 5331 | 2.37 | 2.86 | 2.98 | 0.286 | 0.84 | all 10 passed | -| 2026-10-26 | 21:12:23 | 5.75 | 0.72 | 82.4 | 6.03 | 4 | 4 | 16003 | 12976 | 18439 | 4655 | 5335 | 5851 | 2.56 | 3.0 | 3.17 | 0.286 | 0.89 | all 10 passed | -| 2026-10-26 | 21:13:32 | 6.0 | 0.73 | 62.5 | 6.43 | 3 | 3 | 17269 | 14115 | 19639 | 4823 | 5591 | 6076 | 2.73 | 3.09 | 3.28 | 0.287 | 0.95 | all 10 passed | -| 2026-10-26 | 21:16:28 | 6.25 | 0.74 | 169.8 | 3.61 | 3 | 3 | 17740 | 14918 | 19753 | 5004 | 5681 | 6159 | 2.81 | 3.12 | 3.33 | 0.294 | 0.95 | all 10 passed | -| 2026-10-26 | 21:18:21 | 7.0 | 0.78 | 105.7 | 6.21 | 3 | 3 | 18689 | 15510 | 21126 | 5117 | 5865 | 6346 | 2.86 | 3.18 | 3.36 | 0.311 | 1.05 | all 10 passed | -| 2026-10-26 | 21:19:58 | 8.0 | 0.81 | 90.7 | 5.95 | 3 | 3 | 19297 | 15422 | 22142 | 5085 | 5977 | 6569 | 2.89 | 3.22 | 3.4 | 0.333 | 1.13 | all 10 passed | -| 2026-10-26 | 21:22:33 | 9.0 | 0.82 | 148.0 | 5.95 | 3 | 3 | 18982 | 14458 | 22246 | 4882 | 5911 | 6608 | 2.9 | 3.21 | 3.4 | 0.356 | 1.12 | all 10 passed | -| 2026-10-26 | 21:23:31 | 10.0 | 0.82 | 51.6 | 3.46 | 3 | 3 | 19046 | 14401 | 21806 | 4846 | 5926 | 6526 | 2.9 | 3.21 | 3.42 | 0.377 | 1.43 | all 10 passed | +| 2026-11-07 | 16:59:44 | 3.0 | 1.0 | 147.7 | 7.0 | 5 | 5 | 4199 | 2615 | 5850 | 2555 | 3513 | 4455 | 1.02 | 1.19 | 1.33 | 0.23 | 0.51 | all 10 passed | +| 2026-11-07 | 17:04:53 | 4.0 | 0.98 | 302.0 | 6.54 | 4 | 4 | 11593 | 7570 | 15293 | 3760 | 5238 | 6398 | 1.96 | 2.2 | 2.44 | 0.23 | 0.57 | all 10 passed | +| 2026-11-07 | 17:05:52 | 5.0 | 0.99 | 52.5 | 7.1 | 2 | 2 | 21206 | 15367 | 25347 | 5195 | 6370 | 7298 | 2.76 | 3.32 | 3.5 | 0.231 | 0.65 | all 10 passed | +| 2026-11-07 | 17:06:52 | 5.5 | 0.96 | 54.0 | 7.0 | 2 | 2 | 21735 | 13253 | 25172 | 4618 | 6474 | 7197 | 2.54 | 3.34 | 3.5 | 0.251 | 0.82 | all 10 passed | +| 2026-11-07 | 17:07:56 | 5.75 | 0.95 | 57.4 | 7.13 | 2 | 2 | 20875 | 14389 | 25495 | 4846 | 6294 | 7288 | 2.8 | 3.3 | 3.5 | 0.26 | 0.84 | all 10 passed | +| 2026-11-07 | 17:09:34 | 6.0 | 0.94 | 91.3 | 6.84 | 3 | 2 | 21081 | 13762 | 24639 | 4750 | 6336 | 7069 | 2.59 | 3.32 | 3.5 | 0.272 | 0.77 | all 10 passed | +| 2026-11-07 | 17:10:51 | 6.25 | 0.92 | 70.5 | 7.26 | 2 | 2 | 20016 | 11798 | 24017 | 4270 | 6126 | 6928 | 2.39 | 3.25 | 3.49 | 0.282 | 0.79 | all 10 passed | +| 2026-11-07 | 17:11:51 | 7.0 | 0.89 | 54.1 | 6.8 | 2 | 2 | 20124 | 10863 | 24475 | 4229 | 6155 | 7058 | 2.29 | 3.25 | 3.49 | 0.3 | 1.18 | all 10 passed | +| 2026-11-07 | 17:13:04 | 8.0 | 0.83 | 67.0 | 7.12 | 2 | 2 | 18057 | 9826 | 22876 | 3564 | 5737 | 6714 | 2.29 | 3.12 | 3.43 | 0.331 | 1.17 | all 10 passed | +| 2026-11-07 | 17:15:41 | 9.0 | 0.76 | 150.5 | 6.28 | 1 | 1 | 15045 | 5995 | 21958 | 2638 | 5067 | 6543 | 2.18 | 2.91 | 3.39 | 0.356 | 1.24 | all 10 passed | +| 2026-11-07 | 17:20:48 | 10.0 | 0.77 | 300.2 | 7.04 | 1 | 1 | 15283 | 6290 | 22095 | 2768 | 5123 | 6565 | 2.21 | 2.92 | 3.39 | 0.377 | 1.29 | all 10 passed | diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 0e860c3..3d7796d 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -1,13 +1,13 @@ | date | time | v_wind | power_ratio | total_time | rt_factor | opt_requests | opts_installed | av_power | min_power | max_power | min_force | av_force | max_force | v_ro_min | v_ro_av | v_ro_max | av_depower | rms_error | success_criteria | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| 2026-10-26 | 20:39:44 | 3.5 | 0.83 | 313.0 | 5.09 | 5 | 5 | 909 | 365 | 1867 | 658 | 857 | 1375 | 0.55 | 1.01 | 1.43 | 0.286 | 0.94 | all 10 passed | -| 2026-10-26 | 20:43:17 | 4.0 | 0.78 | 156.1 | 5.8 | 5 | 5 | 1513 | 890 | 2471 | 1001 | 1184 | 1610 | 0.89 | 1.25 | 1.59 | 0.286 | 0.65 | all 10 passed | -| 2026-10-26 | 21:28:21 | 5.0 | 0.77 | 195.2 | 5.24 | 5 | 5 | 3144 | 2335 | 4251 | 1443 | 1807 | 2648 | 1.3 | 1.73 | 2.02 | 0.286 | 0.71 | all 10 passed | -| 2026-10-26 | 20:50:06 | 6.0 | 0.74 | 84.5 | 5.3 | 6 | 6 | 5592 | 4486 | 7043 | 2180 | 2673 | 3728 | 1.61 | 2.09 | 2.29 | 0.286 | 0.73 | all 10 passed | -| 2026-10-26 | 20:50:47 | 7.0 | 0.72 | 34.2 | 5.16 | 5 | 5 | 8959 | 7265 | 11186 | 3110 | 3662 | 4654 | 1.86 | 2.45 | 2.75 | 0.286 | 0.81 | all 10 passed | -| 2026-10-26 | 20:52:04 | 8.0 | 0.71 | 69.8 | 5.32 | 5 | 5 | 13382 | 10826 | 16030 | 3945 | 4757 | 5595 | 2.34 | 2.81 | 3.08 | 0.286 | 0.84 | all 10 passed | -| 2026-10-26 | 20:53:20 | 8.25 | 0.7 | 69.8 | 5.27 | 5 | 5 | 14677 | 11987 | 17567 | 4180 | 5044 | 5837 | 2.49 | 2.91 | 3.13 | 0.286 | 0.86 | all 10 passed | -| 2026-10-26 | 20:54:56 | 8.5 | 0.7 | 89.4 | 5.19 | 5 | 5 | 16040 | 13016 | 19217 | 4374 | 5331 | 6089 | 2.6 | 3.01 | 3.2 | 0.286 | 0.88 | all 10 passed | -| 2026-10-26 | 20:57:15 | 9.0 | 0.71 | 132.1 | 2.9 | 4 | 4 | 16839 | 13926 | 20130 | 4660 | 5491 | 6294 | 2.73 | 3.06 | 3.26 | 0.289 | 0.79 | all 10 passed | -| 2026-10-26 | 20:59:22 | 10.0 | 0.74 | 120.7 | 5.38 | 4 | 4 | 17610 | 13987 | 21233 | 4707 | 5645 | 6533 | 2.75 | 3.11 | 3.33 | 0.306 | 0.9 | all 10 passed | -| 2026-10-26 | 21:01:23 | 11.0 | 0.77 | 114.1 | 2.74 | 3 | 3 | 18069 | 13808 | 21901 | 4743 | 5734 | 6599 | 2.78 | 3.15 | 3.38 | 0.32 | 0.9 | all 10 passed | +| 2026-11-07 | 16:41:40 | 3.5 | 1.02 | 275.9 | 5.91 | 6 | 6 | 1887 | 819 | 3234 | 1169 | 2038 | 3061 | 0.69 | 0.9 | 1.08 | 0.23 | 0.47 | all 10 passed | +| 2026-11-07 | 16:43:05 | 4.0 | 0.99 | 68.7 | 6.76 | 3 | 3 | 3159 | 1730 | 5032 | 1474 | 2198 | 3137 | 1.17 | 1.41 | 1.65 | 0.23 | 0.51 | all 10 passed | +| 2026-11-07 | 16:44:41 | 5.0 | 0.99 | 89.5 | 6.6 | 3 | 3 | 6178 | 2804 | 9100 | 1654 | 2842 | 4099 | 1.68 | 2.14 | 2.46 | 0.23 | 0.63 | all 10 passed | +| 2026-11-07 | 16:47:05 | 6.0 | 0.98 | 136.8 | 6.64 | 3 | 3 | 11351 | 6534 | 14836 | 2813 | 4269 | 5204 | 2.03 | 2.65 | 2.91 | 0.23 | 0.67 | all 10 passed | +| 2026-11-07 | 16:48:04 | 7.0 | 0.96 | 52.6 | 6.89 | 3 | 3 | 17734 | 9493 | 21763 | 3680 | 5672 | 6593 | 2.3 | 3.1 | 3.38 | 0.23 | 0.77 | all 10 passed | +| 2026-11-07 | 16:49:11 | 8.0 | 0.93 | 60.7 | 6.97 | 2 | 2 | 20711 | 10578 | 25815 | 3956 | 6262 | 7405 | 2.37 | 3.28 | 3.5 | 0.244 | 0.84 | all 10 passed | +| 2026-11-07 | 16:51:07 | 8.25 | 0.92 | 109.9 | 6.93 | 2 | 2 | 19989 | 9328 | 25439 | 3616 | 6109 | 7310 | 2.27 | 3.24 | 3.5 | 0.253 | 0.77 | all 10 passed | +| 2026-11-07 | 16:52:21 | 8.5 | 0.91 | 67.5 | 7.12 | 3 | 3 | 20252 | 10104 | 24795 | 3809 | 6166 | 7196 | 2.29 | 3.26 | 3.5 | 0.265 | 0.87 | all 10 passed | +| 2026-11-07 | 16:54:35 | 9.0 | 0.89 | 127.4 | 6.94 | 3 | 2 | 18731 | 7938 | 23959 | 3246 | 5849 | 6973 | 2.17 | 3.16 | 3.48 | 0.279 | 0.8 | all 10 passed | +| 2026-11-07 | 16:55:42 | 10.0 | 0.85 | 61.2 | 6.37 | 3 | 3 | 18485 | 7623 | 23917 | 3006 | 5799 | 7008 | 2.05 | 3.15 | 3.48 | 0.301 | 0.87 | all 10 passed | +| 2026-11-07 | 16:57:05 | 11.0 | 0.82 | 76.6 | 6.28 | 2 | 2 | 17631 | 8008 | 22996 | 3040 | 5636 | 6748 | 2.14 | 3.09 | 3.44 | 0.322 | 0.8 | all 10 passed | diff --git a/notebooks/results.jl b/notebooks/results.jl index 0dcf299..9ee2dc3 100644 --- a/notebooks/results.jl +++ b/notebooks/results.jl @@ -5,6 +5,7 @@ try; import KaimonSlate; catch; error("This is a Kaimon Slate notebook — runni

Simulation Results Reel-Out testing at Maasvlakte, NL

+
Reel-out video — click to play @@ -15,6 +16,12 @@ try; import KaimonSlate; catch; error("This is a Kaimon Slate notebook — runni css""" /* the export's auto-generated doc header duplicates this cell's own title — this IS the title */ .exp-titleblock { display: none; } +.site-nav { display: flex; justify-content: flex-end; margin: .25rem 0 .5rem; } +.site-button { + display: inline-block; padding: .35rem 1rem; border: 1px solid currentColor; border-radius: 6px; + color: inherit; text-decoration: none; opacity: .85; +} +.site-button:hover { opacity: 1; background: rgba(127, 127, 127, .15); } .video-frame { position: relative; width: 100%; max-width: 800px; margin: 1rem auto 0; } .video-poster { display: block; width: 100%; height: auto; border-radius: 6px; cursor: pointer; } .video-poster[hidden] { display: none; } @@ -129,7 +136,7 @@ The average mechanical power during reel-out, the force, and the speed are shown #%% md id=wind_speed_hint @md""" -The optimal trajectory of the kite, the shape of the figure of eight, depends on the tether length and on the wind speed. Below, use the slider to adjust the ground wind speed, and see the shape of the desired and actual trajectory the kite flies. For each figure-of-eight, the AWETrim quasi-static optimizer calculated the optimal trajectory. +The optimal trajectory of the kite, the shape of the figure of eight, depends on the tether length and on the wind speed. Below, use the slider to adjust the ground wind speed, and see the shape of the desired and actual trajectory the kite flies. For each figure-of-eight, the AWETrim quasi-static optimizer calculated the optimal trajectory. Note that at 3 m/s no run was performed and the run at 3.5 m/s is shown instead, here and in all plots below. """ #%% code id=wind_speed_bind @@ -138,7 +145,7 @@ The optimal trajectory of the kite, the shape of the figure of eight, depends on #%% web id=pattern_plot controls=wind_speed @web(html"""
- flight pattern, 3 m/s + flight pattern, 3.5 m/s flight pattern, 4 m/s flight pattern, 5 m/s flight pattern, 6 m/s @@ -249,7 +256,7 @@ state machine over the whole run. The first subplot, `d ', shows the distance be #%% web id=time_series_plot controls=wind_speed @web(html"""
- time series, 3 m/s + time series, 3.5 m/s time series, 4 m/s time series, 5 m/s time series, 6 m/s @@ -298,7 +305,7 @@ mechanical power over the whole run, and the cumulative mechanical energy. #%% web id=power_plot controls=wind_speed @web(html"""
- power, 3 m/s + power, 3.5 m/s power, 4 m/s power, 5 m/s power, 6 m/s @@ -348,7 +355,7 @@ kite speed over the whole run. #%% web id=aerodynamics_plot controls=wind_speed @web(html"""
- aerodynamics, 3 m/s + aerodynamics, 3.5 m/s aerodynamics, 4 m/s aerodynamics, 5 m/s aerodynamics, 6 m/s diff --git a/notebooks/results_cabauw.jl b/notebooks/results_cabauw.jl index 153cf52..cf77c6a 100644 --- a/notebooks/results_cabauw.jl +++ b/notebooks/results_cabauw.jl @@ -5,10 +5,17 @@ try; import KaimonSlate; catch; error("This is a Kaimon Slate notebook — runni

Simulation Results Reel-Out testing at Cabauw, NL

+ """, css""" /* the export's auto-generated doc header duplicates this cell's own title — this IS the title */ .exp-titleblock { display: none; } +.site-nav { display: flex; justify-content: flex-end; margin: .25rem 0 .5rem; } +.site-button { + display: inline-block; padding: .35rem 1rem; border: 1px solid currentColor; border-radius: 6px; + color: inherit; text-decoration: none; opacity: .85; +} +.site-button:hover { opacity: 1; background: rgba(127, 127, 127, .15); } """) #%% code id=columns_bind diff --git a/src/RobustKiteControllers.jl b/src/RobustKiteControllers.jl index b27283a..4062384 100644 --- a/src/RobustKiteControllers.jl +++ b/src/RobustKiteControllers.jl @@ -35,7 +35,7 @@ export V3_TURN_RATE_COEFFS, turn_rate_coeffs, turn_rate_depower_range, V3_TURN_R export reload_turn_rate_table!, try_turn_rate_coeffs, stack_fits # Wind-speed-dependent winch law lookup table -export winch_f_low, winch_force_limit, winch_table_lookup, winch_table_select +export winch_f_low, winch_kv, winch_force_limit, winch_table_lookup, winch_table_select # Figure-of-eight run metrics export fig8_metrics, print_fig8_metrics, reelout_power, reelout_ringing diff --git a/src/awetrim_client.jl b/src/awetrim_client.jl index 695d859..fa2b880 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -53,6 +53,62 @@ const RKC_ROOT = normpath(joinpath(@__DIR__, "..")) "Default server address; every function below takes `url` to override it." const AWETRIM_URL = "http://127.0.0.1:8000" +""" +The AWETrim ROM every request flies against, as `rom_config_path` of `/init` and +`/aero_forces`: a file name resolved by the server next to its system file +(`data/LEI-V3-KITE/` of the AWETrim checkout). [`opt_init`](@ref) refuses a reply +that names another file. +""" +const AWETRIM_ROM = "rom_config_aerostructural.yaml" +"The AWETrim checkout `bin/install_awetrim` creates and `bin/run_server` starts." +const AWETRIM_DIR = normpath(joinpath(RKC_ROOT, "..", "AWETrim")) +""" +Bridle mass [kg] the AWETrim model flies with: V3Kite's, its bridle lines plus the point +masses on them (`examples/validate_masses.jl`), instead of the 2.58 kg of AWETrim's file. +""" +const AWETRIM_BRIDLE_MASS = 1.62 + +""" + awetrim_system_config(; awetrim_dir = AWETRIM_DIR, bridle_mass = AWETRIM_BRIDLE_MASS, + dir = joinpath(RKC_ROOT, "output", "awetrim_system")) -> path + +Write AWETrim's LEI-V3 system file into `dir` with the bridle's `mass` set to +`bridle_mass`, copy the ROM files next to it (the server resolves `rom_config_path` +against the system file's folder) and return its path, the `system_config_path` of +[`InitParams`](@ref). The line is replaced in the text, so the comments survive; a file +already up to date is not rewritten. Returns `nothing`, with a warning, when there is no +AWETrim checkout at `awetrim_dir`: the server then flies its own file. +""" +function awetrim_system_config(; awetrim_dir = AWETRIM_DIR, bridle_mass = AWETRIM_BRIDLE_MASS, + dir = joinpath(RKC_ROOT, "output", "awetrim_system")) + src = joinpath(awetrim_dir, "data", "LEI-V3-KITE") + if !isfile(joinpath(src, "system.yaml")) + @warn "No AWETrim system file in $src: the server flies its own bridle mass." maxlog = 1 + return nothing + end + lines = readlines(joinpath(src, "system.yaml")) + i_bridle = findfirst(l -> occursin(r"^\s*bridle:\s*$", l), lines) + isnothing(i_bridle) && error("No `bridle:` block in $(joinpath(src, "system.yaml"))") + indent = length(match(r"^\s*", lines[i_bridle]).match) + i_mass = nothing + for i in i_bridle+1:length(lines) + l = lines[i] + !isempty(strip(l)) && length(match(r"^\s*", l).match) <= indent && break + occursin(r"^\s*mass:", l) && (i_mass = i; break) + end + isnothing(i_mass) && error("No bridle `mass:` in $(joinpath(src, "system.yaml"))") + old = match(r"^(\s*mass:\s*)([^\s#]+)", lines[i_mass]) + lines[i_mass] = old.captures[1] * "$bridle_mass" * + " # kg, V3Kite's bridle, set by RobustKiteControllers (was $(old.captures[2]))" + mkpath(dir) + file = joinpath(dir, "system.yaml") + text = join(lines, '\n') * '\n' + (isfile(file) && read(file, String) == text) || write(file, text) + for rom in filter(f -> startswith(f, "rom_config") && endswith(f, ".yaml"), readdir(src)) + cp(joinpath(src, rom), joinpath(dir, rom); force = true) + end + return file +end # --------------------------------------------------------------------------- # The shared structs (as agreed) @@ -105,6 +161,26 @@ struct Trajectory elevation::Vector{Float64} # [deg], from the ground plane end +""" + AngleOfAttackLimits(; min = nothing, max = nothing) + +Bounds on the angle of attack the optimizer may trim at [deg], in the ROM's own angle; an +omitted side keeps the server's default (0° and 14°). Sent with `/init`, kept by the session. +""" +Base.@kwdef struct AngleOfAttackLimits + min::Union{Float64, Nothing} = nothing + max::Union{Float64, Nothing} = nothing +end + +""" + aoa_limits(tos) -> Union{AngleOfAttackLimits, Nothing} + +The angle-of-attack limits a request carries: `tos.box.angle_of_attack_min` as the floor, +`nothing` (the server's default) when it is `NaN`. +""" +aoa_limits(tos) = isnan(tos.box.angle_of_attack_min) ? nothing : + AngleOfAttackLimits(; min = tos.box.angle_of_attack_min) + Base.@kwdef struct InitParams name::String length::Float64 # initial length of the tether @@ -120,6 +196,9 @@ Base.@kwdef struct InitParams depower::Union{DepowerSpec, Nothing} = nothing min_turn_radius::Union{Float64, Nothing} = nothing # [m], 0/nothing = off pattern_limits::Union{PatternLimits, Nothing} = nothing + rom_config_path::String = AWETRIM_ROM + system_config_path::Union{String, Nothing} = nothing # `nothing`: the server's own file + angle_of_attack_limits::Union{AngleOfAttackLimits, Nothing} = nothing end Base.@kwdef struct StepParams @@ -171,6 +250,7 @@ struct InitReply pattern_limits::Union{PatternLimits, Nothing} state::String n_points::Union{Int, Nothing} + rom_config::Union{String, Nothing} end """ @@ -201,6 +281,9 @@ StructTypes.StructType(::Type{DepowerSpec}) = StructTypes.Struct() StructTypes.StructType(::Type{PatternLimits}) = StructTypes.Struct() # Left out of the JSON while unset: a server older than the field rejects the key even as null. StructTypes.omitempties(::Type{PatternLimits}) = (:climb_angle_max,) +StructTypes.StructType(::Type{AngleOfAttackLimits}) = StructTypes.Struct() +StructTypes.omitempties(::Type{AngleOfAttackLimits}) = (:min, :max) +StructTypes.omitempties(::Type{InitParams}) = (:angle_of_attack_limits,) # --------------------------------------------------------------------------- # Failed-request cache @@ -248,15 +331,22 @@ under the narrower keys rather than reusing them. The project is NOT part of the key, and needs not be: the server sees only the request, so two projects that send the same request pose the same problem and -share the entry. What the key cannot see is the server's own configuration -(kite, tether, solver defaults) — clear the cache after an AWETrim upgrade. +share the entry. A `system_config_path` enters by the file's CONTENT, not its path, so +a changed bridle mass gets new keys. What the key cannot see is the rest of the +server's configuration (solver defaults, the code) — clear the cache after an AWETrim +upgrade. The key is a [`stable_hash`](@ref), not Base's `hash`, so it survives a Julia upgrade. `"v4-"` retires the `hash`-based `"v3-"` keys. """ function opt_request_key(p::InitParams) - return "v4-" * stable_hash(Tuple(_key_fields(getfield(p, f)) - for f in fieldnames(InitParams) if f !== :name)) + fields = Tuple(_key_fields(getfield(p, f)) for f in fieldnames(InitParams) + if !(f in (:name, :system_config_path, :angle_of_attack_limits))) + isnothing(p.system_config_path) || + (fields = (fields..., stable_hash(read(p.system_config_path, String)))) + isnothing(p.angle_of_attack_limits) || + (fields = (fields..., _key_fields(p.angle_of_attack_limits))) + return "v4-" * stable_hash(fields) end """ @@ -275,7 +365,7 @@ stable_hash(x) = bytes2hex(sha256(repr(x)))[1:16] # Hashing the structs themselves would not do: the default `hash` of a struct # holding a `Vector` goes by the vector's identity, not its contents, so two # equal requests would never share a key. -_key_fields(x::Union{WinchParams, InflowConditions, Trajectory, DepowerSpec, PatternLimits, +_key_fields(x::Union{WinchParams, InflowConditions, Trajectory, DepowerSpec, PatternLimits, AngleOfAttackLimits, StepParams}) = Tuple(_key_fields(getfield(x, f)) for f in fieldnames(typeof(x))) # `symmetric` and `climb_angle_max` came last; each is left out while off, so the keys cached @@ -477,6 +567,13 @@ function report_status_error(path, err) return nothing end +""" +File every POST to the server is appended to, one JSON line `{"path": ..., "body": ...}` +per request, for replaying the exact requests in AWETrim; `nothing` (the default) records +nothing. Set it in the REPL: `RobustKiteControllers.OPT_REQUEST_RECORD[] = "output/requests.jsonl"`. +""" +const OPT_REQUEST_RECORD = Ref{Union{String, Nothing}}(nothing) + function post(path, payload; url = AWETRIM_URL, timeout = 600, attempts = 3) # The server closes idle keep-alive connections long before the run's next # request (a re-optimization is one lap apart, ~13 s), so a pooled socket is @@ -494,6 +591,9 @@ function post(path, payload; url = AWETRIM_URL, timeout = 600, attempts = 3) # re-sending either recomputes and overwrites what the server holds under the # optimization's name — nothing accumulates. body = JSON3.write(payload) + isnothing(OPT_REQUEST_RECORD[]) || + open(io -> println(io, "{\"path\":", JSON3.write(path), ",\"body\":", body, "}"), + OPT_REQUEST_RECORD[], "a") for attempt in 1:attempts isdefined(HTTP, :close_idle_connections!) && HTTP.close_idle_connections!() try @@ -574,7 +674,7 @@ function as_init_reply(reply) as_depower(opt_get(reply, "depower")), opt_float(reply, "min_turn_radius"), as_pattern_limits(opt_get(reply, "pattern_limits")), - reply["state"], opt_get(reply, "n_points")) + reply["state"], opt_get(reply, "n_points"), opt_get(reply, "rom_config")) end function as_step_reply(reply) @@ -602,10 +702,26 @@ optimization happens yet — that is [`opt_step`](@ref). The reply also states what the coming solves will run under: `depower.mode`, `min_turn_radius` and `pattern_limits`, each `nothing` where the server's own -default applies. +default applies, and the ROM it built the session on, which [`check_rom`](@ref) +compares with `params.rom_config_path` ([`AWETRIM_ROM`](@ref) unless set). """ function opt_init(params::InitParams; url = AWETRIM_URL) - return as_init_reply(JSON3.read(post("/init", params; url), Dict{String,Any})) + reply = as_init_reply(JSON3.read(post("/init", params; url), Dict{String,Any})) + check_rom(reply, params) + return reply +end + +""" + check_rom(reply::InitReply, params::InitParams) + +Throw unless the server built the session on the ROM `params` asked for: `reply.rom_config` +must name the file `params.rom_config_path` names. +""" +function check_rom(reply::InitReply, params::InitParams) + used = something(reply.rom_config, "(not reported)") + basename(used) == basename(params.rom_config_path) || + error("AWETrim built the session on $used, not on $(params.rom_config_path).") + return nothing end """ @@ -1317,6 +1433,17 @@ function optimizer_session(tos, inflow, replay_paths, log_name) return (; el_center_seed_base, el_center_seed, startup_seed_offset, guess_az, guess_el, opt_chain) end +""" + StartupNoPath(msg) + +Thrown by [`solve_startup`](@ref) when no seed of the startup solve gave a path: every one sent +was answered 422, or every one within reach is cached as bad. `msg` says what to try. +""" +struct StartupNoPath <: Exception + msg::String +end +Base.showerror(io::IO, e::StartupNoPath) = print(io, e.msg) + """ solve_startup(tos, make_params, solve, start_params, el_center_seed_base, l_set, winch, inflow; failure_file = OPT_FAILURE_CACHE) @@ -1324,19 +1451,20 @@ end guess_az, guess_el) The STARTUP solve, from `start_params` (the shipped guess) and, if the optimizer answers -422 (no path from that seed), from the seeds of `startup_retry_el_offsets` in order, then -whole degrees walked outward. `make_params(el_center)` builds the `/init` request seeded at -that centre elevation and `solve(params)` sends it, returning `(result, seed_trajectory)`. +422 (no path from that seed), from the guess sizes of `startup_retry_guess_sizes` at the same +centre, then the seeds of `startup_retry_el_offsets` in order. `make_params(el_center, size)` builds the `/init` request seeded at that centre +elevation with guess `size = (guess_a, guess_b)` (`nothing` = the shipped size), and +`solve(params)` sends it, returning `(result, seed_trajectory)`. A request the failure cache (`failure_file`, read and written only under `tos.server.opt_failure_cache`) -records as bad is skipped and costs no retry; a 422 is recorded. +records as bad is skipped, but still uses up its place in that list, so a rerun of the same +run skips what failed before instead of sending new seeds; a 422 is recorded. -Returns the result with the request and seed that produced it. Throws when every seed -within reach is cached as bad, or when the optimizer answered 422 to all that were sent; -both messages say what to try. +Returns the result with the request and seed that produced it. Throws a +[`StartupNoPath`](@ref) when every seed is cached as bad, or when the optimizer +answered 422 to all that were sent; both messages say what to try. """ function solve_startup(tos, make_params, solve, start_params, el_center_seed_base, l_set, winch, inflow; failure_file::AbstractString = OPT_FAILURE_CACHE) - # A 422 is retried from `startup_retry_el_offsets` in order; cached failures are skipped and cost no retry. opt_result = nothing opt_seed_trajectory = nothing el_center_seed = el_center_seed_base @@ -1344,13 +1472,13 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas guess_az = guess_el = nothing last_422 = nothing cached_msg = nothing - sent = 0 - budget = 1 + length(tos.guess.startup_retry_el_offsets) - sent_offsets = Float64[] - for offset in startup_seed_offsets(tos.guess.startup_retry_el_offsets) - sent < budget || break + sizes = [(s[1], s[2]) for s in tos.guess.startup_retry_guess_sizes] + seeds = [[(0.0, nothing)]; [(0.0, s) for s in sizes]; + [(o, nothing) for o in tos.guess.startup_retry_el_offsets]] + budget = length(seeds) + for (i, (offset, size)) in enumerate(seeds) el_center = el_center_seed_base + offset - params = offset == 0 ? start_params : make_params(el_center) + params = offset == 0 && isnothing(size) ? start_params : make_params(el_center, size) cached = tos.server.opt_failure_cache ? opt_failed_before(params; file = failure_file) : nothing if !isnothing(cached) cached_msg = @sprintf("This exact request failed before (%s, recorded \ @@ -1362,14 +1490,14 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas @warn cached_msg continue end - sent += 1 - push!(sent_offsets, offset) - sent == 1 || + i == 1 || isnothing(size) || + @warn @sprintf("Startup solve at %.0f° failed: retry %d/%d from a %.0f° x %.0f° \ + guess (startup_retry_guess_sizes).", + el_center_seed_base, i - 1, budget - 1, size...) + i == 1 || !isnothing(size) || @warn @sprintf("Startup solve at %.0f° failed: retry %d/%d from a \ - guess centred at %.0f° (%+.1f°, startup_retry_el_offsets%s).", - el_center_seed_base, sent - 1, budget - 1, el_center, offset, - offset in tos.guess.startup_retry_el_offsets ? "" : - " walked outward past the listed seeds") + guess centred at %.0f° (%+.1f°, startup_retry_el_offsets).", + el_center_seed_base, i - 1, budget - 1, el_center, offset) try opt_result, opt_seed_trajectory = solve(params) start_params = params @@ -1384,20 +1512,21 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas end end isnothing(opt_result) && isnothing(last_422) && - error(cached_msg * "\n\nEvery seed within reach of startup_retry_el_offsets \ - is cached as bad. Retry them with `clear_opt_failures()`, drop an entry \ + throw(StartupNoPath(cached_msg * "\n\nEvery seed of startup_retry_guess_sizes and \ + startup_retry_el_offsets is cached as bad. Retry them with `clear_opt_failures()`, drop an entry \ from $failure_file, or set opt_failure_cache: false in \ - data/traj_opt.yaml.") - isnothing(opt_result) && error(""" + data/traj_opt.yaml.")) + isnothing(opt_result) && throw(StartupNoPath(""" The optimizer returned no path: $(String(copy(last_422.response.body))) Three candidates, most likely first: * the INITIAL GUESS is too far from the optimum for IPOPT to reach \ it. Here that is guess_a = $(tos.guess.guess_a)°, guess_b = \ $(tos.guess.guess_b)°, guess_el_center = $(el_center_seed_base)° of \ - data/traj_opt.yaml$(length(sent_offsets) == 1 ? "" : - ", and the retry seeds at offsets $(sent_offsets[2:end])° " * - "failed too (startup_retry_el_offsets)"), which seeds the \ + data/traj_opt.yaml$(isempty(tos.guess.startup_retry_el_offsets) ? "" : + ", and the retry seeds at offsets $(tos.guess.startup_retry_el_offsets)° " * + "failed too (startup_retry_el_offsets)")$(isempty(sizes) ? "" : + ", and so did the guess sizes $(sizes)° (startup_retry_guess_sizes)"), which seeds the \ request and nothing else — widening or raising it changes the \ guess, not the flown path. Measured at 150 m and 6 m/s: 20°/11° \ at 18° does not converge, 30°/12° and 20°/11°-at-26° do. @@ -1406,7 +1535,7 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas m/s against $(inflow.wind_speed) m/s of wind at 6 m. * these conditions genuinely have no solution. - `bin/run_server log` carries the solver's own output.""") + `bin/run_server log` carries the solver's own output.""")) return (; opt_result, opt_seed_trajectory, start_params, el_center_seed, startup_seed_offset, guess_az, guess_el) end @@ -1454,7 +1583,9 @@ function free_speed_reference(tos, wc, inflow, guess_az, guess_el, lengths; trajectory = Trajectory(collect(guess_az), collect(guess_el)), input_depower = depower_seed(tos, inflow.wind_speed), reg_weight = REG_WEIGHT, - min_turn_radius, pattern_limits) + min_turn_radius, pattern_limits, + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)) reply = chain_init(ref_chain, params) result = chain_step(ref_chain, StepParams(l, ref_winch, reply.trajectory)) if !isnothing(result.metrics) diff --git a/src/fc_settings.jl b/src/fc_settings.jl index 31d205e..9d1f4db 100644 --- a/src/fc_settings.jl +++ b/src/fc_settings.jl @@ -280,6 +280,11 @@ $(TYPEDFIELDS) low_wind_l_tether::Vector{Float64} = Float64[] "Startup guess's centre elevation [deg] (`guess_el_center`)" low_wind_guess_el_center::Vector{Float64} = Float64[] + "Wind speed [m/s] at `low_wind_height` at and above which reel-out starts at + `high_wind_l_tether`; `0.0` = off" + high_wind_speed::Float64 = 0.0 + "Tether length [m] reel-out starts at from `high_wind_speed` on" + high_wind_l_tether::Float64 = 0.0 end """ @@ -313,6 +318,12 @@ $(TYPEDFIELDS) min_elevation = 10.0 "Min. size, fraction of `f8_a` (per side), `f8_b` (span)" min_span_frac = 0.7 + "Laps criterion: figure-eight laps the scored window must hold" + min_laps = 2.5 + "Laps criterion at and above `min_laps_high_wind`" + min_laps_high = 2.5 + "Wind speed at `low_wind.low_wind_height` from which `min_laps_high` applies [m/s]; `0.0` = off" + min_laps_high_wind = 0.0 end # The field list is written out rather than `$(TYPEDFIELDS)`, so that each type links to diff --git a/src/low_wind_schedule.jl b/src/low_wind_schedule.jl index b2a1884..5e9c92b 100644 --- a/src/low_wind_schedule.jl +++ b/src/low_wind_schedule.jl @@ -39,11 +39,24 @@ low_wind_reference(fcs::FC_Settings, project_set) = calc_wind_factor(AtmosphericModel(project_set; nowindfield = true), fcs.low_wind.low_wind_height) * project_set.v_wind +""" + min_laps_at(fcs::FC_Settings, project_set) -> Float64 + +The laps a run must fly to pass: `fcs.run.min_laps_high` when the wind at +`fcs.low_wind.low_wind_height` ([`low_wind_reference`](@ref)) is at or above +`fcs.run.min_laps_high_wind > 0`, `fcs.run.min_laps` otherwise. +""" +min_laps_at(fcs::FC_Settings, project_set) = + fcs.run.min_laps_high_wind > 0 && + low_wind_reference(fcs, project_set) >= fcs.run.min_laps_high_wind ? + fcs.run.min_laps_high : fcs.run.min_laps + """ apply_low_wind_schedule!(fcs, tos, project_set; keep = ()) -> NamedTuple Overwrite the starting tether length `project_set.l_tether` and `tos.guess.guess_el_center` with -[`low_wind_schedule`](@ref) at [`low_wind_reference`](@ref). A name in `keep` (e.g. the +[`low_wind_schedule`](@ref) at [`low_wind_reference`](@ref), and the starting length alone with +`fcs.low_wind.high_wind_l_tether` at and above `fcs.low_wind.high_wind_speed`. A name in `keep` (e.g. the keys of a run's overrides) is left alone; `tos = nothing` skips the guess, for a caller without the optimizer's settings. Call it once, after the wind-speed override and before anything is built from the settings. Returns `(; v_ref, values)`, `values` @@ -63,8 +76,17 @@ function apply_low_wind_schedule!(fcs::FC_Settings, tos, project_set; keep = ()) end v_ref = low_wind_reference(fcs, project_set) values = low_wind_schedule(fcs, v_ref) - isnothing(values) && return (; v_ref, values) keep = Symbol.(collect(keep)) + if isnothing(values) + lw.high_wind_speed > 0 && v_ref >= lw.high_wind_speed || return (; v_ref, values) + lw.high_wind_l_tether > 0 || + error("low_wind: high_wind_l_tether must be > 0 when high_wind_speed is set.") + :l_tether in keep || :l_tethers in keep || (project_set.l_tether = lw.high_wind_l_tether) + @info @sprintf("High wind at %.2f m/s (%.0f m): l_tether = %.1f m%s.", v_ref, + lw.low_wind_height, project_set.l_tether, + isempty(keep) ? "" : ", overrides kept: " * join(keep, ", ")) + return (; v_ref, values = (; l_tether = project_set.l_tether)) + end :l_tether in keep || :l_tethers in keep || (project_set.l_tether = values.l_tether) isnothing(tos) || :guess_el_center in keep || (tos.guess.guess_el_center = values.guess_el_center) @info @sprintf("Low-wind schedule at %.2f m/s (%.0f m): l_tether = %.1f m, guess_el_center = %.2f°%s.", diff --git a/src/pattern_limits.jl b/src/pattern_limits.jl index 9eb1554..7dd62e8 100644 --- a/src/pattern_limits.jl +++ b/src/pattern_limits.jl @@ -102,13 +102,43 @@ function azimuth_max_at(tos, wind_speed) tos.box.pattern_azimuth_max_high : tos.box.pattern_azimuth_max end +""" + shape_high(tos, wind_speed) -> Bool + +Whether the high-wind shape limits apply at `wind_speed`, the same wind as +[`elevation_amplitude_max_at`](@ref) reads: at and above `tos.box.pattern_shape_wind_ref`, +never when that is `0.0` or the wind is not known. +""" +shape_high(tos, wind_speed) = + tos.box.pattern_shape_wind_ref > 0 && !isnothing(wind_speed) && + wind_speed >= tos.box.pattern_shape_wind_ref + +""" + symmetric_at(tos, wind_speed) -> Bool + +Whether the figure is held mirror-symmetric at `wind_speed`: `tos.box.pattern_symmetric_shape_high` +where [`shape_high`](@ref), `tos.box.pattern_symmetric` below. +""" +symmetric_at(tos, wind_speed) = + shape_high(tos, wind_speed) ? tos.box.pattern_symmetric_shape_high : tos.box.pattern_symmetric + +""" + climb_angle_max_at(tos, wind_speed) -> Float64 + +The climb-angle ceiling [deg] at `wind_speed`: `tos.box.pattern_climb_angle_max_shape_high` +where [`shape_high`](@ref), `tos.box.pattern_climb_angle_max` below; `0.0` is off. +""" +climb_angle_max_at(tos, wind_speed) = + shape_high(tos, wind_speed) ? tos.box.pattern_climb_angle_max_shape_high : + tos.box.pattern_climb_angle_max + """ pattern_limits_from(tos; elevation_min = nothing, wind_speed = nothing) -> Union{PatternLimits, Nothing} The box the optimized pattern must stay in, from the `pattern_*` fields of `data/traj_opt.yaml`; each is in degrees and each is off at `0.0`, and -`tos.box.pattern_symmetric` adds the mirror-symmetry rows and `tos.box.pattern_climb_angle_max` +[`symmetric_at`](@ref) adds the mirror-symmetry rows and [`climb_angle_max_at`](@ref) the climb-angle ceiling. `nothing` when all are off, which leaves the optimizer's own defaults alone. @@ -123,8 +153,8 @@ function pattern_limits_from(tos; elevation_min = nothing, wind_speed = nothing) elevation_min = on(elevation_min), elevation_amplitude_max = on(elevation_amplitude_max_at(tos, wind_speed)), - symmetric = tos.box.pattern_symmetric ? true : nothing, - climb_angle_max = on(tos.box.pattern_climb_angle_max)) + symmetric = symmetric_at(tos, wind_speed) ? true : nothing, + climb_angle_max = on(climb_angle_max_at(tos, wind_speed))) all(isnothing, (limits.azimuth_max, limits.elevation_min, limits.elevation_amplitude_max, limits.symmetric, limits.climb_angle_max)) && return nothing diff --git a/src/reelout_loop.jl b/src/reelout_loop.jl index 224cd89..46cadad 100644 --- a/src/reelout_loop.jl +++ b/src/reelout_loop.jl @@ -317,7 +317,9 @@ function request_reopt!(st::RunState, setup, t, phase, l_now) reg_weight = REG_WEIGHT, depower = depower_spec(tos), min_turn_radius = st.opt_r_min, - pattern_limits = st.opt_box_now) + pattern_limits = st.opt_box_now, + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)) # A known failure is served by `opt_chain`, not skipped here: skipping would leave # the chain on the warm lineage, and every later step would miss the cache. reopt_reply = chain_init(opt_chain, reopt_params) @@ -596,7 +598,9 @@ function solve_cold!(st::RunState, setup, l_now, el_seed, box) reg_weight = REG_WEIGHT, depower = depower_spec(tos), min_turn_radius = st.opt_r_min, - pattern_limits = box) + pattern_limits = box, + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)) reply = chain_init(opt_chain, params) chain_step(opt_chain, StepParams(opt_length(l_now), winch_reopt, reply.trajectory); wait = false) diff --git a/src/run_inputs.jl b/src/run_inputs.jl index 2cf28cc..04a1821 100644 --- a/src/run_inputs.jl +++ b/src/run_inputs.jl @@ -21,6 +21,8 @@ Every input a caller of `simple_opt_reelout.jl` may pass with `hook_settle`: the V1 stability hook. - `replay_paths`: a scenario folder whose optimizer results are replayed. - `output_path`: where the log goes instead of `output/`, `nothing` for `output/`. +- `l_tether`: the tether length reel-out starts at [m], `nothing` for the project's or the + low-wind schedule's; set by [`setup_with_startup`](@ref) to retry a startup at a longer one. """ run_input_defaults() = (; show_plots = true, run_archive = true, path_tr_project = nothing, @@ -28,4 +30,5 @@ run_input_defaults() = wc_overrides = Dict{Symbol, Any}(), set_overrides = Dict{Symbol, Any}(), steer_disturbance = nothing, xtrack_offset = nothing, xtrack_phase = 5, hold_compliance = nothing, steer_gain_factor = 1.0, steer_gain_feedback_only = false, - extra_steer_delay = 0, hook_settle = 15.0, replay_paths = nothing, output_path = nothing) + extra_steer_delay = 0, hook_settle = 15.0, replay_paths = nothing, output_path = nothing, + l_tether = nothing) diff --git a/src/run_setup.jl b/src/run_setup.jl index 4abbf06..9a9f490 100644 --- a/src/run_setup.jl +++ b/src/run_setup.jl @@ -249,7 +249,9 @@ function setup_run(inputs; init_model) # an override of the same setting wins. apply_low_wind_schedule!(fcs, tos, project_set; keep = (keys(inputs.fcs_overrides)..., keys(inputs.tos_overrides)..., - keys(inputs.set_overrides)...)) + keys(inputs.set_overrides)..., + (isnothing(inputs.l_tether) ? () : (:l_tether,))...)) + isnothing(inputs.l_tether) || (project_set.l_tether = Float64(inputs.l_tether)) l_tether = project_set.l_tether # Whether `min_power_frac`/`min_power_frac_prev` are bypassed for a candidate predicting `pred` diff --git a/src/startup_path.jl b/src/startup_path.jl index ae136a5..3f16754 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -7,23 +7,26 @@ # beyond reading `setup.s.dt`, so the package still does not depend on it. """ - startup_params(setup, el_center) -> InitParams + startup_params(setup, el_center, size = nothing) -> InitParams The startup `/init` request seeded with the guess lemniscate centred at -`el_center` [deg] and fitted into the startup box `opt_box` ([`guess_in_box`](@ref)), as the -cold re-optimizations are; everything else comes from `tos`, the inflow and the -first-lap winch. +`el_center` [deg], `size = (guess_a, guess_b)` [deg] or `tos`'s own, and fitted into the +startup box `opt_box` ([`guess_in_box`](@ref)), as the cold re-optimizations are; everything +else comes from `tos`, the inflow and the first-lap winch. """ -function startup_params(setup, el_center) +function startup_params(setup, el_center, size = nothing) (; tos, l_set, winch_first_lap, inflow, opt_r_min, opt_box) = setup - guess_a, guess_b, guess_el = guess_in_box(tos.guess.guess_a, tos.guess.guess_b, el_center, opt_box) + a, b = something(size, (tos.guess.guess_a, tos.guess.guess_b)) + guess_a, guess_b, guess_el = guess_in_box(a, b, el_center, opt_box) az, el = figure_eight_path(guess_a, guess_b, 0.0, guess_el, 0.0, GUESS_POINTS) InitParams(; name = OPT_NAME, length = opt_length(l_set), winch_params = winch_first_lap, inflow_conditions = inflow, trajectory = Trajectory(collect(az), collect(el)), input_depower = depower_seed(tos, inflow.wind_speed), reg_weight = REG_WEIGHT, depower = depower_spec(tos), - min_turn_radius = opt_r_min, pattern_limits = opt_box) + min_turn_radius = opt_r_min, pattern_limits = opt_box, + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)) end """ @@ -64,7 +67,8 @@ function solve_startup_path!(setup, st::RunState) (; tos, el_center_seed_base, l_set, winch, inflow) = setup t_solve_start = time() (; opt_result, el_center_seed, startup_seed_offset, guess_az, guess_el) = - solve_startup(tos, el -> startup_params(setup, el), params -> startup_solve(setup, params), + solve_startup(tos, (el, size) -> startup_params(setup, el, size), + params -> startup_solve(setup, params), startup_params(setup, setup.el_center_seed), el_center_seed_base, l_set, winch, inflow) st.opt_result = opt_result @@ -76,6 +80,38 @@ function solve_startup_path!(setup, st::RunState) opt_startup_solve_s = time() - t_solve_start) end +""" + setup_with_startup(inputs; init_model) -> (setup, st) + +[`setup_run`](@ref) and the startup solve ([`solve_startup_path!`](@ref)), with the `RunState` +`st` it wrote into. When no seed gives a path ([`StartupNoPath`](@ref)), the steps of +`startup_retry_length_steps` are added in turn to the length the run started at, and the +model is set up again there through the `l_tether` run input. Other errors, and the last +`StartupNoPath`, are rethrown. +""" +function setup_with_startup(inputs; init_model) + l_start = nothing + for k in 0:typemax(Int) + setup = setup_run(inputs; init_model) + st = RunState(; l_set = setup.l_set, opt_r_scale = setup.opt_r_scale, + opt_r_min = setup.opt_r_min, + depower_flown_opt = setup.fcs.course.depower_setpoint) + l_start = something(l_start, setup.l_tether) + steps = setup.tos.guess.startup_retry_length_steps + try + merge_into!(setup, solve_startup_path!(setup, st)) + return setup, st + catch exc + exc isa StartupNoPath && k < length(steps) || rethrow() + l_next = l_start + steps[k + 1] + @warn @sprintf("No startup path at %.1f m: retry %d/%d at %.1f m \ + (startup_retry_length_steps).", setup.l_tether, k + 1, + length(steps), l_next) + inputs = merge(inputs, (; l_tether = l_next)) + end + end +end + """ log_startup_reply(fcs, tos, opt_result, opt_r_min) diff --git a/src/startup_retry.jl b/src/startup_retry.jl index 5668668..5a4439b 100644 --- a/src/startup_retry.jl +++ b/src/startup_retry.jl @@ -153,23 +153,3 @@ function record_converged!(ladder::RetryLadder, ask, margin) ladder.width_ok, ladder.relax_width = ask.az_min, false return ladder end - -""" - startup_seed_offsets(listed; max_abs = 10.0) -> Vector{Float64} - -The centre-elevation offsets [deg] the startup solve may be seeded from, in -order: 0 (the shipped guess), then `listed` (`startup_retry_el_offsets`), then -every whole degree not yet in the list by growing magnitude, negative first, out -to `max_abs`. The tail only exists so a seed the failure cache rejects can be -replaced by one that has not been tried; how many of them are SENT is the -caller's budget, not this list. An empty `listed` never retries, so it gets no -tail either. -""" -function startup_seed_offsets(listed; max_abs = 10.0) - offsets = [0.0; listed] - isempty(listed) && return offsets - for k in 1.0:max_abs, o in (-k, k) - o in offsets || push!(offsets, o) - end - return offsets -end diff --git a/src/traj_opt_settings.jl b/src/traj_opt_settings.jl index c7186a0..4c1a17b 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -49,6 +49,10 @@ $(TYPEDFIELDS) guess_el_center_wind_ref = 0.0 "Guess elevation offsets tried in order when the startup solve fails [deg]" startup_retry_el_offsets::Vector{Float64} = Float64[] + "Guess sizes `[guess_a, guess_b]` tried in order when the startup solve fails, before the offsets [deg]" + startup_retry_guess_sizes::Vector{Vector{Float64}} = Vector{Float64}[] + "Lengths added to the starting tether, in order, when no seed converges; the model is set up again at each [m]" + startup_retry_length_steps::Vector{Float64} = Float64[] end """ @@ -114,6 +118,16 @@ $(TYPEDFIELDS) pattern_symmetric::Bool = false "Steepest climb angle of the optimized path [deg]; `0.0` = off" pattern_climb_angle_max = 0.0 + "Wind speed aloft (at `pattern_elevation_amplitude_max_wind_height`) at and above which the + `_shape_high` values below replace `pattern_symmetric` and `pattern_climb_angle_max` [m/s]; + `0.0` = off" + pattern_shape_wind_ref = 0.0 + "`pattern_symmetric` at and above `pattern_shape_wind_ref`" + pattern_symmetric_shape_high::Bool = false + "`pattern_climb_angle_max` at and above `pattern_shape_wind_ref` [deg]; `0.0` = off" + pattern_climb_angle_max_shape_high = 0.0 + "Lowest angle of attack the optimizer may trim at, in the ROM's own angle [deg]; `NaN` = the server's 0°" + angle_of_attack_min = NaN end """ @@ -468,6 +482,11 @@ function TrajOptSettings(filename::String; path = rkc_data_path()) error("size_box_growth must be 0 (off) or >= 1, got $(tos.reopt.size_box_growth).") 0 <= tos.box.pattern_climb_angle_max < 90 || error("pattern_climb_angle_max must be in [0, 90), got $(tos.box.pattern_climb_angle_max).") + 0 <= tos.box.pattern_climb_angle_max_shape_high < 90 || + error("pattern_climb_angle_max_shape_high must be in [0, 90), got \ + $(tos.box.pattern_climb_angle_max_shape_high).") + tos.box.pattern_shape_wind_ref >= 0 || + error("pattern_shape_wind_ref must be >= 0, got $(tos.box.pattern_shape_wind_ref).") tos.reopt.challenge_growth == 0 || tos.reopt.challenge_growth >= 1 || error("challenge_growth must be 0 (off) or >= 1, got $(tos.reopt.challenge_growth).") tos.guess.guess_a > 0 && tos.guess.guess_b > 0 || diff --git a/src/winch_setup.jl b/src/winch_setup.jl index a7e7909..5a9c27b 100644 --- a/src/winch_setup.jl +++ b/src/winch_setup.jl @@ -51,6 +51,7 @@ function build_winch(project, project_set, fcs) wc = load_wc_settings(KiteUtils.wc_settings(project); dt = dt0) # Wind-dependent floor; NOT the entry guard's floor, that is fcs.winch.entry_f_min. wc.f_low = winch_f_low(project_set.v_wind; project) + wc.kv = winch_kv(project_set.v_wind; project) # The soft law's floor cannot go below ~700 N, so it is off at low wind; see `winch_force_limit`'s docstring. wc.force_limit = winch_force_limit(project_set.v_wind; project) wpc = WinchPosController(wc; dt = dt0) # the length loop `step!` used to own diff --git a/src/winch_table.jl b/src/winch_table.jl index dd0ec1b..2ccbc62 100644 --- a/src/winch_table.jl +++ b/src/winch_table.jl @@ -46,6 +46,18 @@ which is `FC_Settings.winch.entry_f_min`; see `docs/fig8_tuning_log.md`. winch_f_low(v_wind; project = project_file()) = winch_table_lookup(v_wind, "f_low"; project) +""" + winch_kv(v_wind; project = project_file()) -> Float64 + +Wind-speed-dependent gain of the reel-out law `v_set = kv * sqrt(force)`, from the `kv` +column of the project's `winch_table` file (see [`winch_table_lookup`](@ref)). Overrides +the flat `kv` of `data/wc_settings.yaml`; the caller assigns it to `WCSettings.kv`, which +the winch flies and the optimizer requests send alike. A lower `kv` reels out slower, so +the kite keeps more of a weak wind. +""" +winch_kv(v_wind; project = project_file()) = + winch_table_lookup(v_wind, "kv"; project) + """ winch_table_select(v_wind, key; project = project_file()) -> Any diff --git a/test/fake_awetrim_server.jl b/test/fake_awetrim_server.jl index aef57f4..4660c24 100644 --- a/test/fake_awetrim_server.jl +++ b/test/fake_awetrim_server.jl @@ -50,7 +50,8 @@ function fake_server(; fail = Float64[], fail_warm = false, path == "/trajectory" && return json(200, table(power[])) path == "/init" && return json(200, Dict("name" => body["name"], "length" => body["length"], - "trajectory" => body["trajectory"], "state" => "ready")) + "trajectory" => body["trajectory"], "state" => "ready", + "rom_config" => "/srv/LEI-V3-KITE/" * body["rom_config_path"])) if path == "/step" validation && return json(422, Dict("detail" => [Dict("loc" => ["body", "bogus"], "msg" => "extra field")])) diff --git a/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index d73d375..b301f3e 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -16,7 +16,7 @@ import RobustKiteControllers: InflowConditions, WinchParams, Trajectory, InitPar opt_failures, opt_failed_before, record_opt_failure!, clear_opt_failures, OptChain, _write_chain_entry, _chain_entry, _cached_422, chain_step, chain_status, chain_trajectory, record_opt_success!, solve_startup, as_step_reply, as_pattern_limits, guess_el_center_seed, - reelout_anchor_ratio, with_file_lock, HTTP, JSON3 + reelout_anchor_ratio, with_file_lock, InitReply, check_rom, AWETRIM_ROM, HTTP, JSON3 const INFLOW = InflowConditions(; wind_speed = 8.0, wind_direction = 270.0, profile_law = 3) const WINCH = WinchParams("reelout", 0.04, 700.0, 7200.0) @@ -47,7 +47,7 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n p = params() k = opt_request_key(p) # Pinned: a change here silently orphans every cached entry, so it must be deliberate. - @test k == "v4-2549e297cc9588f5" + @test k == "v4-6cb401c6baa240c0" @test opt_request_key(params(; name = "other run")) == k # the label is not the problem p2 = InitParams(; name = "v4", length = 150.0, winch_params = WINCH, inflow_conditions = INFLOW, @@ -58,7 +58,10 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n for q in (params(; length = 150.00027), params(; el_center = 26.0), params(; min_turn_radius = 12.0), params(; depower = DepowerSpec()), params(; input_depower = 1.7), + params(; rom_config_path = "rom_config_aerostructural.yaml"), params(; pattern_limits = PatternLimits(; azimuth_max = 28.0)), + params(; angle_of_attack_limits = + RobustKiteControllers.AngleOfAttackLimits(; min = -7.0)), params(; winch_params = WinchParams(; mode = "reelout", k_v = 0.04, f_min = 700.0, f_max = 7200.0, softplus_beta = 0.03)), @@ -67,6 +70,8 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n profile_law = 3, z0 = 0.001))) @test opt_request_key(q) != k end + # Unset, the angle-of-attack limits are neither sent nor in the key. + @test !occursin("angle_of_attack", RobustKiteControllers.JSON3.write(p)) @test opt_request_key(params(; length = -0.0)) != opt_request_key(params(; length = 0.0)) @test opt_request_key(params(; length = NaN)) == opt_request_key(params(; length = NaN)) # `symmetric` is left out of the key while unset, so the keys from before it stay valid. @@ -146,7 +151,7 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n oc = inited_chain(dir) sp = StepParams(150.0, WINCH) key = step_key(oc, sp) - @test key == "c2-645a7afd33368830" + @test key == "c2-efd9af2b6e454680" table = Dict{String, Any}("metrics" => Dict{String, Any}("avg_power_W" => 1000.0)) _write_chain_entry(oc, Dict{String, Any}("key" => key, "status" => "converged", "reply" => reply_dict(), "table" => table, @@ -228,7 +233,7 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n center(p) in bad && throw(_cached_422(Dict("reason" => "test"))) return (; seed = center(p)), traj(center(p)) end - r = solve_startup(tos, el -> params(; el_center = el), solve, params(), 25.0, 150.0, + r = solve_startup(tos, (el, _) -> params(; el_center = el), solve, params(), 25.0, 150.0, WINCH, INFLOW; failure_file = file) return r, sent end @@ -249,12 +254,22 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n catch exc exc end - @test err isa ErrorException && occursin("The optimizer returned no path", err.msg) + @test err isa RobustKiteControllers.StartupNoPath && occursin("The optimizer returned no path", err.msg) @test length(opt_failures(; file)) == 3 # the budget: the guess and two retries - # The same run again: the three are cached as bad and skipped without costing a retry, - # so the seeds walk outward past the listed ones: offset +1 is the next one sent. - r, sent = run([25.0, 24.0, 27.0]; file) - @test sent == [26.0] && r.startup_seed_offset == 1.0 + # The same run again: the three are cached as bad and skipped, each using up its place, + # so nothing is sent and the error says why. + err = try + run([25.0, 24.0, 27.0]; file) + nothing + catch exc + exc + end + @test err isa RobustKiteControllers.StartupNoPath && occursin("cached as bad", err.msg) + # Only the shipped guess cached: it is skipped and the first listed seed is sent. + file = joinpath(mktempdir(), "failures.yaml") + record_opt_failure!(params(), "422 from /step"; file) + r, sent = run([25.0]; file) + @test sent == [24.0] && r.startup_seed_offset == -1.0 # No retry seeds and the only one cached as bad: nothing is sent, and the error says why. tos0 = TrajOptSettings(tos; startup_retry_el_offsets = Float64[]) @@ -266,11 +281,25 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n catch exc exc end - @test err isa ErrorException && occursin("cached as bad", err.msg) + @test err isa RobustKiteControllers.StartupNoPath && occursin("cached as bad", err.msg) # With the failure cache off it is neither read nor written. tosoff = TrajOptSettings(tos; opt_failure_cache = false) r, sent = run([25.0]; file, tos = tosoff) @test sent == [25.0, 24.0] && length(opt_failures(; file)) == 1 + + # A guess size of startup_retry_guess_sizes is tried at the same centre before the offsets. + toss = TrajOptSettings(tos; startup_retry_guess_sizes = [[40.0, 20.0]], opt_failure_cache = false) + widths = Float64[] + make = (el, sz) -> isnothing(sz) ? params(; el_center = el) : + params(; trajectory = Trajectory([0.0, sz[1], 0.0, -sz[1]], el .+ [0.0, 10.0, 0.0, -10.0])) + solve_w = function (p) + push!(widths, maximum(p.trajectory.azimuth)) + maximum(p.trajectory.azimuth) < 15 && throw(_cached_422(Dict("reason" => "test"))) + return (; seed = 0.0), traj() + end + r = @test_logs (:warn, r"40° x 20° guess") match_mode = :any solve_startup(toss, make, + solve_w, params(), 25.0, 150.0, WINCH, INFLOW; failure_file = file) + @test widths == [10.0, 40.0] && r.startup_seed_offset == 0.0 end @testset "reply_parsing" begin @@ -294,6 +323,16 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n @test isnothing(as_pattern_limits(nothing)) end + @testset "rom_check" begin + p = params() + @test p.rom_config_path == AWETRIM_ROM + init_reply(rom) = InitReply("v4", 150.0, WINCH, INFLOW, traj(), 1.6, 1.0, true, + nothing, nothing, nothing, "initialized", 4, rom) + @test isnothing(check_rom(init_reply("/srv/data/LEI-V3-KITE/" * AWETRIM_ROM), p)) + @test_throws ErrorException check_rom(init_reply("/srv/rom_config_aerostructural.yaml"), p) + @test_throws ErrorException check_rom(init_reply(nothing), p) # a server that does not say + end + @testset "seed_and_anchor_ratio" begin tos = TrajOptSettings(; guess_el_center = 30.0, guess_el_center_high = 32.0, guess_el_center_wind_ref = 8.0) @test guess_el_center_seed(tos, 7.99) == 30.0 diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 3a9baf2..4871f1c 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -805,6 +805,11 @@ end @test winch_f_low(4.0; project = p) == 700.0 @test winch_f_low(3.5; project = p) ≈ 525.0 @test winch_f_low(9.0; project = p) == 700.0 + @test winch_kv(3.5; project = p) == 0.020 + @test winch_kv(4.0; project = p) == 0.030 + @test winch_kv(4.5; project = p) ≈ 0.0354 + @test winch_kv(5.0; project = p) == 0.0408 + @test winch_kv(12.0; project = p) == 0.0408 # Clamped outside the identified range, NEVER extrapolated: below the # first row and above the last, the end value is returned unchanged. @@ -857,8 +862,7 @@ end # Integer input is converted, like turn_rate_coeffs'. @test winch_f_low(4; project = p) == winch_f_low(4.0; project = p) - # kv is flat in wc_settings.yaml, no longer a column of this table. - @test_throws ErrorException winch_table_lookup(6.0, "kv"; project = p) + @test winch_table_lookup(6.0, "kv"; project = p) == winch_kv(6.0; project = p) @test winch_table_lookup(6.0, "f_low"; project = p) == winch_f_low(6.0; project = p) # A column the table does not have is named in the error, not a KeyError # from inside the comprehension. @@ -1069,8 +1073,16 @@ end # The shipped file loads, and its guess is NOT the reel-out pattern: those # 20°/11°-at-18° values do not converge at 150 m and 6 m/s (2026-08-18). @test tos.server.base_url == "http://127.0.0.1:8000" - @test tos.guess.guess_a == 30.0 - @test tos.guess.guess_b == 12.0 + # 30°/12° -> 32°/14° with retries at 30°/13° and 34°/15°: rom_config_aerostructural2 + # turns ~43 % less, and no single size starts it at 5-8.25 m/s (2026-10-28); 26°/11° + # starts 5 m/s with the 12.62 kg wing (2026-10-30). + @test tos.guess.guess_a == 32.0 + @test tos.guess.guess_b == 14.0 + @test tos.guess.startup_retry_guess_sizes == [[30.0, 13.0], [34.0, 15.0], [26.0, 11.0]] + @test tos.guess.startup_retry_length_steps == [10.0, 20.0] + @test tos.box.angle_of_attack_min == -7.0 + @test RobustKiteControllers.aoa_limits(tos).min == -7.0 + @test isnothing(RobustKiteControllers.aoa_limits(TrajOptSettings(tos; angle_of_attack_min = NaN))) # 30 since 2026-08-20 (ad08158), not the 26 that was the narrower basin in # tether LENGTH before it: raising wc_settings' f_high 7600 -> 8000 moves the # request's f_max with it, and at 150.0 m / 9 m/s the 22 seed throws IPOPT's @@ -1080,19 +1092,24 @@ end # 8° -> 10° at 11 m/s: the 8° cap that closes the tall basin below 11 m/s # cannot hold the 17.3°-tall figure that IS the good basin there (2026-09-21). # 8° -> 8.5°: Maasvlakte 3.5 m/s needs 8.12° (2026-09-24). - @test tos.box.pattern_elevation_amplitude_max == 8.5 + # 8.5° -> 15° for rom_config_aerostructural2, which needs a taller figure (2026-10-28). + @test tos.box.pattern_elevation_amplitude_max == 15.0 # 10° -> 11°: Cabauw 10 at f_high 7200 N needs 10.5-10.7° at 150 m (2026-09-23). # 11° -> 13°: with the wing drag Cabauw 10 needs more (2026-10-05, commit 1290d50). - @test tos.box.pattern_elevation_amplitude_max_high == 13.0 + # 13° -> 15° (2026-10-28), not below the low-wind cap. + # 15° -> 20° and the step 13.2 -> 16.5 m/s: only Cabauw 9 and 10 m/s (17.4 and 19.3) + # are above it, and they converge only with the larger box (2026-10-31). + @test tos.box.pattern_elevation_amplitude_max_high == 20.0 # Keyed on the 100 m wind: Maasvlakte 10 m/s is 12.9 there, Cabauw 7 m/s 13.5. - @test tos.box.pattern_elevation_amplitude_max_wind_ref == 13.2 + @test tos.box.pattern_elevation_amplitude_max_wind_ref == 16.5 @test tos.box.pattern_elevation_amplitude_max_wind_height == 100.0 # 32° at and above the same wind: under the 45° climb limit Cabauw 10 needs ±28.4-28.8°, # and the startup solve 2-3° of room beyond (2026-10-03). 32° -> 36° with the wing drag # (1290d50), then 36° -> 34°: at 36° the re-optimizations at Cabauw 10 grew to ±32° x 21° # and tracked worse (2026-10-05, b68455e). - @test tos.box.pattern_azimuth_max == 28.0 - @test tos.box.pattern_azimuth_max_high == 34.0 + # Both 36° for rom_config_aerostructural2 (2026-10-28). + @test tos.box.pattern_azimuth_max == 36.0 + @test tos.box.pattern_azimuth_max_high == 45.0 # The optimizer's lopsided basins are closed by the mirror-symmetry rows (2026-09-25). @test tos.box.pattern_symmetric === true @test TrajOptSettings().box.pattern_symmetric === false @@ -1155,6 +1172,7 @@ end @test fro.low_wind.low_wind_l_tether[end] == 150.0 @test fro.low_wind.low_wind_guess_el_center[end] == TrajOptSettings("traj_opt.yaml").guess.guess_el_center @test low_wind_schedule(fro, 4.51).guess_el_center == 25.0 + @test fro.low_wind.high_wind_speed == 14.0 && fro.low_wind.high_wind_l_tether == 170.0 tos = TrajOptSettings("traj_opt.yaml") # Below the struct's 1.0, but no longer for the old reason: the request is # scaled from this number and the optimizer honours it, so what the gate @@ -1194,13 +1212,11 @@ end let L = 187.0, el_ask = asind(tos.gates.min_height / L) @test path_min_height([0.0, 1.0], [el_ask, el_ask + 5], L) ≈ tos.gates.min_height end - # The depower seed follows the wind, and only UPWARDS from the reference: - # all six runs of the 5.0-7.0 m/s scan converged from 1.6 m, so the - # reference may not drop below the top of that scan, while 8.0 m/s did not - # converge from it (2026-08-20, a 422 out of /step's stage 1, which carries - # no turn-radius constraint at all). - @test tos.seed.input_depower_wind_ref >= 7.0 - @test tos.seed.input_depower_per_wind > 0 + # The depower seed is flat for rom_config_aerostructural2: the ramp seeded 8 m/s at + # 1.75 m, which fails where 1.5 m converges (2026-10-28). The flight-corrected ROM + # needed it upwards from 7 m/s (2026-08-20). + @test tos.seed.input_depower == 1.5 + @test tos.seed.input_depower_per_wind == 0.0 # The DEFAULT is the behaviour before the ramp existed: input_depower # whatever the wind. @test TrajOptSettings().seed.input_depower_per_wind == 0.0 diff --git a/test/test_pattern_limits.jl b/test/test_pattern_limits.jl index 37afca6..402e271 100644 --- a/test/test_pattern_limits.jl +++ b/test/test_pattern_limits.jl @@ -48,6 +48,21 @@ import RobustKiteControllers: PatternLimits, pattern_limits_from, elevation_min_ @test pattern_limits_from(tos; wind_speed = 12.0).elevation_amplitude_max == 6.0 end + @testset "shape_high" begin + tos = TrajOptSettings(off; pattern_symmetric = true, pattern_climb_angle_max = 45, + pattern_shape_wind_ref = 12.5) + @test pattern_limits_from(tos; wind_speed = 12.4).symmetric === true + @test pattern_limits_from(tos; wind_speed = 12.4).climb_angle_max == 45.0 + # At and above the ref wind both are off (the _shape_high defaults), and with them the box. + @test isnothing(pattern_limits_from(tos; wind_speed = 12.5)) + high = pattern_limits_from(TrajOptSettings(tos; pattern_azimuth_max = 36); wind_speed = 12.5) + @test isnothing(high.symmetric) && isnothing(high.climb_angle_max) + # An unknown wind, or the step disabled, keeps the base values. + @test pattern_limits_from(tos).symmetric === true + @test pattern_limits_from(TrajOptSettings(tos; pattern_shape_wind_ref = 0.0); + wind_speed = 20.0).climb_angle_max == 45.0 + end + @testset "azimuth_max_at" begin # The same wind step as the elevation cap: pattern_elevation_amplitude_max_wind_ref. tos = TrajOptSettings(off; pattern_azimuth_max = 28.0, pattern_azimuth_max_high = 30.0) diff --git a/test/test_settings_files.jl b/test/test_settings_files.jl index 3e13167..818ea0d 100644 --- a/test/test_settings_files.jl +++ b/test/test_settings_files.jl @@ -114,9 +114,25 @@ const GUI_DEFAULT = read(joinpath(rkc_data_path(), "gui.yaml.default"), String) set = KiteUtils.Settings(project) (; wc, wpc, dt0) = build_winch(project, set, FC_Settings(; compliance = 0.0)) @test dt0 == 1 / set.sample_freq && wc.dt == dt0 - # The wind-dependent tables override the file's flat values; kv is not one of them. - @test wc.kv == RobustKiteControllers._wc_settings_value(project, "kv") + # The wind-dependent tables override the file's flat values. + # The start length: 150 m at 10 m/s (12.9 m/s aloft), 170 m from 14 m/s aloft (11 m/s). + fro = FC_Settings(fc_settings(project)) + for (v, l) in ((10.0, 150.0), (11.0, 170.0)) + s = KiteUtils.Settings(project) + RobustKiteControllers.apply_windspeed_override!(s, v) + apply_low_wind_schedule!(fro, nothing, s) + @test s.l_tether == l + end + # The laps criterion: 2.0 from 16.5 m/s aloft (Cabauw 9 m/s is 17.4), 2.5 below (8 m/s, 15.5). + cab = project_file("system_reelout_cabauw.yaml") + fcab = FC_Settings(fc_settings(cab)) + for (v, n) in ((8.0, 2.5), (9.0, 2.0)) + s = KiteUtils.Settings(cab) + RobustKiteControllers.apply_windspeed_override!(s, v) + @test RobustKiteControllers.min_laps_at(fcab, s) == n + end @test wc.f_low == winch_f_low(set.v_wind; project) + @test wc.kv == winch_kv(set.v_wind; project) @test wc.force_limit == winch_force_limit(set.v_wind; project) @test wpc isa WinchPosController # REEL_OUT holds the drum in POSITION mode: any compliance is refused. diff --git a/test/test_startup_pipeline.jl b/test/test_startup_pipeline.jl index 6ae7708..ef6ec0b 100644 --- a/test/test_startup_pipeline.jl +++ b/test/test_startup_pipeline.jl @@ -12,7 +12,7 @@ copy, the optimizer's caches are off, and rejected curves go to a temporary fold using Test using RobustKiteControllers -import RobustKiteControllers: GUI_STATE_FILE_OVERRIDE, FAILED_TRAJECTORY_DIR, setup_run, merge_into!, +import RobustKiteControllers: GUI_STATE_FILE_OVERRIDE, FAILED_TRAJECTORY_DIR, setup_with_startup, merge_into!, solve_startup_path!, log_startup_reply, log_lobe_lift, adopt_startup_path!, finish_startup!, capture_startup_geometry!, startup_feasibility, init_phase5_and_controller!, init_loop_state!, startup_params, startup_solve, write_run_done, step_commands!, record_step!, check_overspeed, @@ -41,14 +41,14 @@ stand_in_model(project, project_set, fcs, wpc, sim_time; turbulence, set_overrid (; dt = 1 / project_set.sample_freq, sys_state = (; l_tether = Float32[project_set.l_tether])) """ - fly_startup(; tos, wind, path_for, fail_warm) -> (; setup, st, err, paths, log, saved) + fly_startup(; tos, wind, path_for, fail_warm, fail) -> (; setup, st, err, paths, log, saved) The script's startup against a fake server whose replies carry `path_for(power)`, the server's power counting 1000 W per converged step; `fail_warm` answers every warm `/step` (the retries) with -a 422. `err` is what the startup threw, if anything; `saved` the curves it saved as rejected. +a 422, as does every `/step` at a length in `fail`. `err` is what the startup threw, if anything; `saved` the curves it saved as rejected. """ function fly_startup(; tos = Dict{Symbol, Any}(), wind = "default", path_for = p -> TIGHT, - fail_warm = false) + fail_warm = false, fail = Float64[]) reply(l, p) = Dict("length" => l, "state" => "converged", "step_index" => 1, "trajectory" => Dict("azimuth" => path_for(p)[1], "elevation" => path_for(p)[2]), "depower" => Dict("mode" => "optimize", "value" => 1.53), @@ -59,7 +59,7 @@ function fly_startup(; tos = Dict{Symbol, Any}(), wind = "default", path_for = p "distance_radial" => [150.0, 180.0]), "spline" => Dict("downloops" => !UP_LOOPS), "metrics" => Dict("avg_power_W" => 18000.0), "optimized_parameters" => Dict("input_depower" => 1.53)) - fs = fake_server(; reply, table, fail_warm) + fs = fake_server(; reply, table, fail_warm, fail) gui = joinpath(mktempdir(), "gui.yaml") write(gui, replace(read(joinpath(rkc_data_path(), "gui.yaml.default"), String), "system_fig8_200m.yaml" => PIPELINE_PROJECT, @@ -76,10 +76,7 @@ function fly_startup(; tos = Dict{Symbol, Any}(), wind = "default", path_for = p :opt_success_cache => false, :opt_failure_cache => false), tos) inputs = merge(run_input_defaults(), (; output_path = mktempdir(), tos_overrides = overrides)) - setup = setup_run(inputs; init_model = stand_in_model) - st = RunState(; l_set = setup.l_set, opt_r_scale = setup.opt_r_scale, - opt_r_min = setup.opt_r_min, depower_flown_opt = setup.fcs.course.depower_setpoint) - merge_into!(setup, solve_startup_path!(setup, st)) + setup, st = setup_with_startup(inputs; init_model = stand_in_model) log_startup_reply(setup.fcs, setup.tos, st.opt_result, setup.opt_r_min) log_lobe_lift(setup.fcs, st.opt_result) st.c1_startup = setup.c1_at_depower(setup.fcs.course.depower_setpoint) @@ -113,6 +110,7 @@ end init = r.log[2][2] @test init["length"] == 150.0 && init["min_turn_radius"] ≈ setup.opt_r_min @test !isnothing(init["pattern_limits"]) + @test init["angle_of_attack_limits"] == Dict("min" => setup.tos.box.angle_of_attack_min) # The reply as installed, and nothing retried: the path clears the default gate. @test st.opt_result.metrics.avg_power_W == 18000.0 && st.opt_power_pred == 18000.0 @test st.opt_paths_raw[1][1] ≈ TIGHT[1] && st.opt_downloops == !UP_LOOPS @@ -125,6 +123,19 @@ end @test st.ccs.el_center == st.el_c_path end + @testset "length_retry" begin + # No seed converges at 150 m: the run is set up again 20 m longer and solves there. + r = @test_logs (:warn, r"No startup path at 150.0 m: retry 1/1 at 170.0 m") match_mode = :any fly_startup(; + tos = Dict{Symbol, Any}(:startup_retry_length_steps => [20.0]), fail = [150.0]) + @test isnothing(r.err) + @test r.setup.l_tether == 170.0 && r.setup.project_set.l_tether == 170.0 + @test last(filter(x -> x[1] == "/init", r.log))[2]["length"] == 170.0 + # Failing there too, the last StartupNoPath is rethrown. + r = fly_startup(; tos = Dict{Symbol, Any}(:startup_retry_length_steps => [20.0]), + fail = [150.0, 170.0]) + @test r.err isa RobustKiteControllers.StartupNoPath + end + @testset "wind_override" begin r = fly_startup(; wind = "8.25") @test isnothing(r.err) diff --git a/test/test_startup_retry.jl b/test/test_startup_retry.jl index d8d8729..02ad5fe 100644 --- a/test/test_startup_retry.jl +++ b/test/test_startup_retry.jl @@ -9,7 +9,7 @@ each attempt pulls and what the ladder learns from an answer. Pure numbers, no o using Test using RobustKiteControllers import RobustKiteControllers: RetryLadder, next_lever, record_422!, record_converged!, - azimuth_amplitude, RETRY_GAIN_MAX, startup_seed_offsets, STARTUP_RETRY_STEP, STARTUP_RETRY_SLACK + azimuth_amplitude, RETRY_GAIN_MAX, STARTUP_RETRY_STEP, STARTUP_RETRY_SLACK @testset verbose = true "startup_retry" begin tos = TrajOptSettings(; min_feasibility_margin = 1.0) @@ -105,16 +105,5 @@ import RobustKiteControllers: RetryLadder, next_lever, record_422!, record_conve record_422!(L, (; el_cap = 19.0, az_min = nothing, r_ask = 8.0)) @test L.cap_bad == 19.0 end - - @testset "startup_seed_offsets" begin - # No listed retries: the shipped guess only, and no walked-out tail. - @test startup_seed_offsets(Float64[]) == [0.0] - # The listed seeds first, in order, then the whole degrees they miss, negative first. - @test startup_seed_offsets([2.0, -1.0]; max_abs = 3.0) == - [0.0, 2.0, -1.0, 1.0, -2.0, -3.0, 3.0] - offsets = startup_seed_offsets([4.0]) - @test length(offsets) == length(unique(offsets)) - @test maximum(abs, offsets) == 10.0 - end end nothing