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..d3f9d3f 100755 --- a/bin/install_awetrim +++ b/bin/install_awetrim @@ -2,8 +2,10 @@ # 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 @@ -14,7 +16,9 @@ # # 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 +28,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 @@ -49,12 +53,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 @@ -89,4 +104,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..7e31397 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-10-31 | 05:17:37 | 3.0 | 0.99 | 127.6 | 5.03 | 6 | 6 | 4538 | 3247 | 6213 | 2940 | 3699 | 4695 | 1.1 | 1.22 | 1.35 | 0.23 | 0.46 | all 10 passed | +| 2026-10-31 | 06:09:13 | 4.0 | 0.97 | 495.1 | 4.67 | 4 | 4 | 11782 | 8828 | 15106 | 4185 | 5299 | 6344 | 2.04 | 2.22 | 2.42 | 0.23 | 0.57 | all 10 passed | +| 2026-10-31 | 04:47:34 | 5.0 | 0.98 | 98.9 | 5.28 | 2 | 2 | 21347 | 15371 | 25340 | 5199 | 6400 | 7294 | 2.76 | 3.33 | 3.5 | 0.23 | 0.67 | all 10 passed | +| 2026-10-31 | 04:49:08 | 5.5 | 0.96 | 83.7 | 5.83 | 2 | 2 | 21626 | 14365 | 24814 | 4900 | 6445 | 7166 | 2.65 | 3.35 | 3.5 | 0.252 | 0.73 | all 10 passed | +| 2026-10-31 | 04:53:35 | 5.75 | 0.95 | 258.9 | 5.52 | 3 | 2 | 22069 | 15823 | 25294 | 5194 | 6527 | 7233 | 2.79 | 3.37 | 3.5 | 0.261 | 0.74 | all 10 passed | +| 2026-10-31 | 04:56:18 | 6.0 | 0.94 | 154.9 | 5.58 | 3 | 2 | 21083 | 13756 | 24636 | 4750 | 6336 | 7068 | 2.59 | 3.32 | 3.5 | 0.272 | 0.77 | all 10 passed | +| 2026-10-31 | 04:57:45 | 6.25 | 0.92 | 78.4 | 5.9 | 2 | 2 | 20083 | 11919 | 24039 | 4321 | 6140 | 6934 | 2.4 | 3.26 | 3.49 | 0.282 | 0.8 | all 10 passed | +| 2026-10-31 | 04:59:29 | 7.0 | 0.88 | 96.2 | 5.55 | 2 | 2 | 19886 | 10864 | 24482 | 4231 | 6108 | 7060 | 2.29 | 3.23 | 3.49 | 0.301 | 1.17 | all 10 passed | +| 2026-10-31 | 08:16:16 | 8.0 | 0.83 | 82.0 | 5.69 | 2 | 2 | 18056 | 9828 | 22878 | 3564 | 5737 | 6713 | 2.29 | 3.12 | 3.43 | 0.331 | 1.17 | all 10 passed | +| 2026-11-01 | 03:42:40 | 9.0 | 0.76 | 216.0 | 4.0 | 1 | 1 | 15044 | 5988 | 21960 | 2640 | 5066 | 6543 | 2.18 | 2.91 | 3.39 | 0.356 | 1.24 | all 10 passed | +| 2026-11-01 | 03:48:50 | 10.0 | 0.77 | 363.4 | 5.32 | 1 | 1 | 15284 | 6292 | 22090 | 2769 | 5123 | 6566 | 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..f8b426b 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-10-30 | 22:30:03 | 3.5 | 1.02 | 150.8 | 4.41 | 6 | 6 | 1890 | 823 | 3234 | 1174 | 2040 | 3060 | 0.7 | 0.9 | 1.08 | 0.23 | 0.47 | all 10 passed | +| 2026-10-30 | 21:58:44 | 4.0 | 0.99 | 107.1 | 4.58 | 3 | 3 | 3161 | 1748 | 5026 | 1486 | 2199 | 3133 | 1.17 | 1.41 | 1.65 | 0.23 | 0.51 | all 10 passed | +| 2026-10-30 | 20:48:30 | 5.0 | 0.99 | 104.4 | 4.58 | 3 | 3 | 6178 | 2793 | 9097 | 1651 | 2840 | 4031 | 1.68 | 2.15 | 2.46 | 0.23 | 0.63 | all 10 passed | +| 2026-10-31 | 04:32:35 | 6.0 | 0.98 | 337.2 | 4.52 | 3 | 3 | 11339 | 6485 | 14850 | 2786 | 4266 | 5206 | 2.02 | 2.64 | 2.91 | 0.23 | 0.67 | all 10 passed | +| 2026-10-31 | 04:34:22 | 7.0 | 0.96 | 87.6 | 5.45 | 3 | 3 | 18256 | 11453 | 22761 | 4168 | 5789 | 6847 | 2.44 | 3.14 | 3.43 | 0.23 | 0.79 | all 10 passed | +| 2026-10-31 | 04:35:48 | 8.0 | 0.93 | 78.4 | 5.8 | 2 | 2 | 20692 | 10540 | 25742 | 3940 | 6258 | 7377 | 2.37 | 3.28 | 3.5 | 0.244 | 0.84 | all 10 passed | +| 2026-10-31 | 04:40:11 | 8.25 | 0.92 | 255.4 | 5.25 | 3 | 2 | 20712 | 11260 | 25554 | 4101 | 6265 | 7373 | 2.41 | 3.28 | 3.5 | 0.256 | 0.8 | all 10 passed | +| 2026-10-30 | 22:35:21 | 8.5 | 0.91 | 117.7 | 5.18 | 2 | 2 | 19388 | 9216 | 24950 | 3585 | 5971 | 7236 | 2.26 | 3.21 | 3.5 | 0.263 | 0.76 | all 10 passed | +| 2026-10-31 | 00:27:14 | 9.0 | 0.89 | 256.1 | 3.15 | 2 | 2 | 17951 | 7943 | 23655 | 3247 | 5689 | 6893 | 2.17 | 3.11 | 3.48 | 0.275 | 0.74 | all 10 passed | +| 2026-10-31 | 01:02:47 | 10.0 | 0.85 | 275.0 | 3.08 | 3 | 3 | 18267 | 7621 | 23897 | 3006 | 5758 | 7008 | 2.05 | 3.13 | 3.48 | 0.301 | 0.82 | all 10 passed | +| 2026-10-31 | 06:12:14 | 11.0 | 0.82 | 59.4 | 5.14 | 2 | 2 | 17625 | 7976 | 23008 | 3029 | 5635 | 6750 | 2.14 | 3.09 | 3.44 | 0.322 | 0.81 | all 10 passed | 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..89a0ccf 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_aerostructural2.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