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, """
+ | V3Kite |
+ AWETrim, ROM trim | |
+ | tape [m] | va [m/s] |
+ AoA [°] | L / D [N] |
+ AoA [°] | L / D [N] | ΔL | ΔD |
+ in ROM band |
+ """)
+ 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, "| $(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
+ println(io, "
")
+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
+ | 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] |
+
""")
+ for res in v3kite
+ println(io, "| $(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
+ println(io, "
")
+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, """
+ - V3Kite L:
lift_drag, the wing's force perpendicular to its apparent
+ wind. V3Kite D: the VSM wing drag plus the drag of all bridle lines, AWETrim's
+ basis; lift_drag's own D holds only the bridle share lumped onto the
+ wing nodes.
+ - AWETrim L / D: the ROM wing (bridle lines included in D, KCU not), at the
+ measured mean va of the V3Kite block.
+ - ROM trim: the ROM's own gravity-free angle of attack. ΔL, ΔD: AWETrim relative
+ to V3Kite.
+
""")
+ 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