From caaa5f9031818dcac4793b60fd234d250b9c9f50 Mon Sep 17 00:00:00 2001 From: ocayon Date: Sun, 8 Nov 2026 12:07:40 +0100 Subject: [PATCH 1/3] Fly against AWETrim's 2026-11-05 LEI-V3 ROM: depower offset, seed band, startup wind ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the same power tape V3Kite and the new ROM do not pull the same: at matched lift + drag resultant V3Kite's tape is the ROM's minus 0.372 m (std 0.010 m; validate_lift_drag.jl + AWETrim's examples/server/identify_depower_offset.py), so depower_offset is -0.074. Power ratio Cabauw 4 m/s 0.52 -> 1.11, Maasvlakte 4 m/s 0.58 -> 1.21; Cabauw 7 0.99, Cabauw 10 0.89 (from 180 m), Maasvlakte 7 1.07, Maasvlakte 10 0.96. The depower seed bounds are the ROM's band, 1.5-2.6 m, and the seed cap 2.45 m: at 10 m/s the optimum is near 2.4 m. The high-wind figure is 17° tall (was 13°) from 12.5 m/s at 100 m (was 13.2), guess centre 33°: at 13° the laps outran the 0.29 1/s steering slew. A startup wind ramp (guess.startup_wind_ramp_start / _step, 7 and 1 m/s, 0 = off) solves at the start wind and steps up warm to the run's: Cabauw 10 m/s failed cold from every seed. Needs AWETrim with the ROM's angle-of-attack range as the reel-out box (feat/server-aero-forces). Open: Cabauw 10 from 150 m; the Maasvlakte 4 m/s phase-5 transition with the kite flying below the turn-rate table's 0.20. Generated with Claude Code --- CHANGELOG.md | 24 +++++++++++++++++++++++ data/traj_opt.yaml | 25 ++++++++++++++++++------ src/opt_request.jl | 6 ++++-- src/startup_path.jl | 36 +++++++++++++++++++++++++++++++++++ src/traj_opt_settings.jl | 5 +++++ test/test_fig8_controller.jl | 9 ++++++--- test/test_startup_pipeline.jl | 28 +++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12776be..da5c1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ ### Changed +- Flies against AWETrim's 2026-11-05 LEI-V3 ROM (`rom_config_aerostructural.yaml`), which + needs AWETrim with the reel-out angle-of-attack box from the ROM's validity (branch + `feat/server-aero-forces`). At the same power tape the two models do not pull the same: + at matched lift + drag resultant V3Kite's tape is the ROM's minus 0.372 m (std 0.010 m, + from `validate_lift_drag.jl` and AWETrim's `examples/server/identify_depower_offset.py`), + so `depower_offset` is -0.074. Power ratio before / after: Cabauw 4 m/s 0.52 / 1.11, + Maasvlakte 4 m/s 0.58 / 1.21; with it Cabauw 7 0.99, Cabauw 10 0.89, Maasvlakte 7 1.07, + Maasvlakte 10 0.96 (2026-11-08). Redo the offset whenever the ROM or V3Kite changes. +- The depower seed bounds are the new ROM's band, 1.5-2.6 m (was 1.1-2.3), and + `input_depower_seed_max` 2.45 m (was 1.85): at 10 m/s the optimum is near 2.4 m and a + seed at 1.85 m cannot be marched. +- The high-wind figure is larger: `pattern_elevation_amplitude_max_high` 17° (was 13°) + from 12.5 m/s at 100 m (was 13.2, so Maasvlakte 10 m/s gets it), with + `guess_el_center_high` 33° (was 30°). With 13° the 10 m/s laps were faster than the + 0.29 1/s steering slew. +- A startup wind ramp (`guess.startup_wind_ramp_start`, `startup_wind_ramp_step`; 7 and + 1 m/s, `0.0` = off): above the start wind the startup solve is solved there and the wind + stepped up warm through `/step` to the run's. Cabauw 10 m/s failed cold from every seed. +- Known open: Cabauw 10 m/s needs a start at 180 m (150 m is infeasible with the steering + slew; `l_tethers` of `settings_reelout_cabauw.yaml`); Maasvlakte 4 m/s reels out at a + power ratio of 1.21 but fails the phase-5 transition at the end of reel-out, flying + more powered (rel_depower ~0.19-0.21) than the turn-rate table covers (0.20-0.375), + which should be extended to about 0.18-0.40. + - `bin/install` records the Julia version in `install_version_.txt` and moves the system image to `bin/kps-image-.so.bak` if it was installed with another Julia version (or the file is missing); `bin/run_julia` stops if the Julia version diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index af0c757..8205cf5 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -15,15 +15,22 @@ traj_opt: 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_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_high: 33.0 # centre elevation at/above the ref wind (a step, not a ramp); 33 keeps + # the bottom of the 17° high-wind figure above min_elevation [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] + # Above this wind the startup solve starts here and steps the wind up warm to the run's + # (src/startup_path.jl, startup_solve); 0.0 = off. The 2026-11-05 ROM depowers far in + # strong wind: Cabauw 10 m/s failed cold from every seed, solved through 7-8-9-10 m/s. + startup_wind_ramp_start: 7.0 # [m/s at the reference height] + startup_wind_ramp_step: 1.0 # [m/s] 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.6 # depower SEED in metres of tape (the ROM's band 1.5-2.6), 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_seed_max: 1.85 # ramp ceiling, below the server's 2.3 m [m] + input_depower_seed_max: 2.45 # ramp ceiling, inside the 2026-11-05 ROM's 2.6 m (1.85 for the old ROM): at + # 10 m/s the optimum is near 2.4 m and a seed at 1.85 cannot be marched [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 # runs (Cabauw 4-10, Maasvlakte 8, 11 m/s) against the wind at the caps' height (100 m): @@ -36,7 +43,11 @@ traj_opt: # Added to every reply's converted rel_depower, to bring the mean power_ratio to ~1.0. # Maasvlakte 2026-10-26: mean power_ratio 0.75 at 0; at -0.013 4/7/10 m/s flew # 0.97/0.88/0.91, i.e. d ln P / d rel_depower ~ -16, so -0.018 (docs/fig8_tuning_log.md). - depower_offset: 0.0 # [-] + # 2026-11-07, 2026-11-05 ROM: V3Kite tape = ROM tape - 0.372 m (std 0.010 m) at matched + # lift + drag resultant, from validate_lift_drag.jl and AWETrim's + # examples/server/identify_depower_offset.py; -0.372 m / 5 m = -0.074. Power ratios + # 0.89-1.11 at 4/7/10 m/s on both sites; redo whenever the ROM or V3Kite changes. + depower_offset: -0.074 # [-] 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] @@ -48,8 +59,10 @@ traj_opt: # 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]; # 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: 17.0 # cap at/above the ref wind (a step, not a ramp); 13 left the + # 2026-11-05 ROM's 10 m/s laps faster than the 0.29 1/s steering slew [deg] + pattern_elevation_amplitude_max_wind_ref: 12.5 # wind AT the height below at/above which the high cap + # (12.5 since 2026-11-07, so Maasvlakte 10 m/s at 12.9 gets it) # 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); diff --git a/src/opt_request.jl b/src/opt_request.jl index 9bb48a3..bcce02f 100644 --- a/src/opt_request.jl +++ b/src/opt_request.jl @@ -32,8 +32,10 @@ function awetrim_depower_to_v3kite(l_dp, tos) return Float64(f(l_dp)) + tos.seed.depower_offset end -"AWETrim's own bounds on `input_depower`, from `src/awetrim/utils/defaults.py` [m]." -const DEPOWER_SEED_BOUNDS = (1.1, 2.3) +"The ROM's depower band, `validity.input_depower` of AWETrim's +`rom_config_aerostructural.yaml` (2026-11-05 identification), to which the server clips +its `input_depower` box [m]." +const DEPOWER_SEED_BOUNDS = (1.5, 2.6) """ depower_seed(tos, wind_speed) -> Float64 diff --git a/src/startup_path.jl b/src/startup_path.jl index ae136a5..f14de6c 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -6,6 +6,12 @@ # the run's `setup` (see `setup_run`) and writes into its `RunState` `st`; none touches the model # beyond reading `setup.s.dt`, so the package still does not depend on it. +"`inflow` at another reference wind speed `w`, its custom-profile speeds scaled along." +with_wind(inflow, w) = InflowConditions(; wind_speed = w, + wind_direction = inflow.wind_direction, profile_law = inflow.profile_law, + alpha = inflow.alpha, z0 = inflow.z0, turbulence = inflow.turbulence, + heights = inflow.heights, speeds = inflow.speeds .* (w / inflow.wind_speed)) + """ startup_params(setup, el_center) -> InitParams @@ -32,8 +38,38 @@ end `/init` with `params`, the optional seeding solve at `opt_warm_start_awe_trim`, then the `/step` under the first-lap winch, the one lap 1 flies. Throws the `HTTP.StatusError` of a 422 unchanged; the caller decides whether that ends the run. + +Above `startup_wind_ramp_start` (`guess:` section, `0.0` = off) the solve starts at that +wind and steps it up warm by `startup_wind_ramp_step` to the run's: in strong wind the +kite has to depower far and fly a large figure, an optimum a cold solve from the guess +does not reach, while each warm step only moves the previous one a little (measured +2026-11-07 on the 2026-11-05 LEI-V3 ROM: Cabauw 10 m/s failed from every seed cold and +solved through 7, 8, 9, 10 m/s). Only the reply at the run's wind is flown. """ function startup_solve(setup, params) + tos = setup.tos + w0 = tos.guess.startup_wind_ramp_start + w0 > 0.0 || return startup_solve_at(setup, params) + inflow = params.inflow_conditions + w_target = inflow.wind_speed + w_target > w0 + 1e-9 || return startup_solve_at(setup, params) + params0 = InitParams(; (k => getfield(params, k) for k in fieldnames(InitParams))..., + inflow_conditions = with_wind(inflow, w0), + input_depower = depower_seed(tos, w0)) + result, seed_trajectory = startup_solve_at(setup, params0) + w = w0 + while w < w_target - 1e-9 + w = min(w + tos.guess.startup_wind_ramp_step, w_target) + @info @sprintf("Startup wind ramp: warm step to %.1f m/s (target %.1f m/s).", w, w_target) + result = chain_step(setup.opt_chain, + StepParams(opt_length(setup.l_set), setup.winch_first_lap); + inflow_conditions = with_wind(inflow, w)) + end + return result, seed_trajectory +end + +"The startup solve of `params` at its own wind, see [`startup_solve`](@ref)." +function startup_solve_at(setup, params) (; opt_chain, tos, winch, rcs, l_set, winch_first_lap) = setup reply = chain_init(opt_chain, params) # Seeding solve: the cold first request fails at low winch force, so it is warmed at `opt_warm_start_awe_trim`. diff --git a/src/traj_opt_settings.jl b/src/traj_opt_settings.jl index c7186a0..8ec705b 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -49,6 +49,11 @@ $(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[] + "Wind [m/s at the reference height] the startup solve starts at when the run's wind + is higher, stepping up warm to it; `0.0` = off (solve at the run's wind directly)" + startup_wind_ramp_start = 0.0 + "Wind step of that ramp [m/s]" + startup_wind_ramp_step = 1.0 end """ diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 3a9baf2..3651dfb 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1083,9 +1083,12 @@ end @test tos.box.pattern_elevation_amplitude_max == 8.5 # 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 - # 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 + # 13° -> 17°: on the 2026-11-05 ROM the 10 m/s laps outran the 0.29 1/s steering + # slew at 13° (2026-11-07). + @test tos.box.pattern_elevation_amplitude_max_high == 17.0 + # Keyed on the 100 m wind: Maasvlakte 10 m/s is 12.9 there, Cabauw 7 m/s 13.5; + # 13.2 -> 12.5 so Maasvlakte 10 m/s gets the high cap too (2026-11-07). + @test tos.box.pattern_elevation_amplitude_max_wind_ref == 12.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 diff --git a/test/test_startup_pipeline.jl b/test/test_startup_pipeline.jl index 6ae7708..bccf65f 100644 --- a/test/test_startup_pipeline.jl +++ b/test/test_startup_pipeline.jl @@ -186,6 +186,33 @@ end r = fly_startup() (; setup, st) = r + @testset "startup_wind_ramp" begin + # Above startup_wind_ramp_start the startup solve runs at that wind and the wind is + # stepped up warm by /step to the run's; the reply at the run's wind is returned. + fs = fake_server() + try + tos = deepcopy(setup.tos) + tos.server.opt_warm_start_awe_trim = 0.0 + tos.guess.startup_wind_ramp_start = 7.0 + tos.guess.startup_wind_ramp_step = 1.0 + su = (; opt_chain = OptChain(fs.url; successes = false, failures = false), tos, + winch = (; use_awe_trim = 0.5), rcs = setup.rcs, l_set = setup.l_set, + winch_first_lap = setup.winch_first_lap) + p = startup_params(setup, setup.el_center_seed) + p = RobustKiteControllers.InitParams(; + (k => getfield(p, k) for k in fieldnames(RobustKiteControllers.InitParams))..., + inflow_conditions = RobustKiteControllers.with_wind(p.inflow_conditions, 9.0)) + @test_logs (:info, r"wind ramp") match_mode = :any startup_solve(su, p) + winds = [b["inflow_conditions"]["wind_speed"] for (path, b) in fs.log + if path in ("/init", "/step") && haskey(b, "inflow_conditions") && + !isnothing(b["inflow_conditions"])] + @test first(winds) == 7.0 && last(winds) == 9.0 + @test count(==("/step"), paths(fs)) == 3 # 7 (first lap), 8, 9 m/s + finally + close(fs.server) + end + end + @testset "seeding_solve" begin # With opt_warm_start_awe_trim above the winch's use_awe_trim, a seeding /step at the # unreduced winch goes before the first-lap one, which starts from its trajectory. @@ -193,6 +220,7 @@ end try tos = deepcopy(setup.tos) tos.server.opt_warm_start_awe_trim = 1.0 + tos.guess.startup_wind_ramp_start = 0.0 # the seeding solve alone, no wind ramp su = (; opt_chain = OptChain(fs.url; successes = false, failures = false), tos, winch = (; use_awe_trim = 0.5), rcs = setup.rcs, l_set = setup.l_set, winch_first_lap = setup.winch_first_lap) From 53a3fcae4bdc84c6bb74d54b2beb8372287d2163 Mon Sep 17 00:00:00 2001 From: ocayon Date: Sun, 8 Nov 2026 12:48:17 +0100 Subject: [PATCH 2/3] High-wind start length: reel out from 180 m above 16 m/s at 100 m Next to the low-wind schedule (low_wind: section of fc_settings_reelout.yaml), high_wind_speed / high_wind_l_tether start the reel-out at a longer tether in strong wind (high_wind_l_tether(), applied by apply_low_wind_schedule!, an l_tether override still wins). On AWETrim's 2026-11-05 ROM Cabauw 10 m/s (19.3 m/s at 100 m) has no feasible startup path at 150 m: the figure big enough for the 0.29 1/s steering slew dips under the 50 m height floor. From 180 m it flies at a power ratio of 0.89. Cabauw 7 (13.5) and Maasvlakte 10 (12.9) keep 150 m. Generated with Claude Code --- CHANGELOG.md | 8 ++++++-- data/fc_settings_reelout.yaml | 5 +++++ src/RobustKiteControllers.jl | 2 +- src/fc_settings.jl | 8 ++++++++ src/low_wind_schedule.jl | 31 +++++++++++++++++++++++++++++-- test/test_fig8_controller.jl | 11 +++++++++++ 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da5c1ca..f2f8996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,12 @@ - A startup wind ramp (`guess.startup_wind_ramp_start`, `startup_wind_ramp_step`; 7 and 1 m/s, `0.0` = off): above the start wind the startup solve is solved there and the wind stepped up warm through `/step` to the run's. Cabauw 10 m/s failed cold from every seed. -- Known open: Cabauw 10 m/s needs a start at 180 m (150 m is infeasible with the steering - slew; `l_tethers` of `settings_reelout_cabauw.yaml`); Maasvlakte 4 m/s reels out at a +- A high-wind start length next to the low-wind schedule (`low_wind:` section of + `fc_settings_reelout.yaml`, `high_wind_speed` / `high_wind_l_tether`, + `high_wind_l_tether()`): at and above 16 m/s at 100 m reel-out starts at 180 m. + Cabauw 10 m/s (19.3 m/s there) has no feasible startup path at 150 m with the + steering slew and the 50 m height floor; Cabauw 7 and Maasvlakte 10 still start at 150 m. +- Known open: Maasvlakte 4 m/s reels out at a power ratio of 1.21 but fails the phase-5 transition at the end of reel-out, flying more powered (rel_depower ~0.19-0.21) than the turn-rate table covers (0.20-0.375), which should be extended to about 0.18-0.40. diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index 44bde6e..ee7185c 100644 --- a/data/fc_settings_reelout.yaml +++ b/data/fc_settings_reelout.yaml @@ -123,6 +123,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] + # At and above high_wind_speed (same height) reel-out starts at high_wind_l_tether; 0.0 = off. + # On AWETrim's 2026-11-05 ROM Cabauw 10 m/s (19.3 at 100 m) has no feasible startup path at + # 150 m and flies from 180 m (2026-11-08); Cabauw 7 (13.5) and Maasvlakte 10 (12.9) fly at 150. + high_wind_speed: 16.0 # [m/s] at low_wind_height + high_wind_l_tether: 180.0 # [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) [-] diff --git a/src/RobustKiteControllers.jl b/src/RobustKiteControllers.jl index b27283a..60f2588 100644 --- a/src/RobustKiteControllers.jl +++ b/src/RobustKiteControllers.jl @@ -52,7 +52,7 @@ export FC_Settings, FC_Course, FC_FeedForward, FC_Pattern, FC_WindRamp, FC_Winch export set_fc_field!, get_fc_field, winch_force_gains, project_file, fc_settings, load_yaml_fields! export apply_overrides! export attractor_distance, guidance_rate, wind_schedule, apply_wind_schedule! -export low_wind_schedule, low_wind_reference, apply_low_wind_schedule! +export low_wind_schedule, high_wind_l_tether, low_wind_reference, apply_low_wind_schedule! export turn_rate_coeffs_file, course_loop_model_file, kite_correction_file, winch_table_file, traj_opt_settings_file # Externally optimized flight path (examples/simple_opt_fig8.jl) diff --git a/src/fc_settings.jl b/src/fc_settings.jl index 31d205e..c117ec5 100644 --- a/src/fc_settings.jl +++ b/src/fc_settings.jl @@ -265,6 +265,9 @@ wind speed at wind speed the schedule is off and the settings files' own values are flown. Empty vectors (the default) switch it off. See [`low_wind_schedule`](@ref). +At and above `high_wind_speed` (same height; `0.0`, the default, = off) reel-out starts +at `high_wind_l_tether` instead, see [`high_wind_l_tether`](@ref). + YAML (reel-out): [`fc_settings_reelout.yaml`](@ref example_fc_settings_reelout). # Fields @@ -280,6 +283,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 """ diff --git a/src/low_wind_schedule.jl b/src/low_wind_schedule.jl index b2a1884..c7321d6 100644 --- a/src/low_wind_schedule.jl +++ b/src/low_wind_schedule.jl @@ -28,6 +28,24 @@ function low_wind_schedule(fcs::FC_Settings, v_ref) return map(c -> c[i] + frac * (c[i + 1] - c[i]), columns) end +""" + high_wind_l_tether(fcs::FC_Settings, v_ref) -> Union{Float64, Nothing} + +The tether length reel-out starts at in strong wind: `fcs.low_wind.high_wind_l_tether` +at and above `high_wind_speed` [m/s] at `low_wind_height`, `nothing` below it and when +`high_wind_speed` is `0.0`. A short tether in strong wind laps a figure big enough for the +steering slew too fast, or puts its bottom under the height floor: on AWETrim's 2026-11-05 +ROM Cabauw 10 m/s (19.3 m/s at 100 m) has no feasible startup path at 150 m and flies +from 180 m (2026-11-08). +""" +function high_wind_l_tether(fcs::FC_Settings, v_ref) + lw = fcs.low_wind + (lw.high_wind_speed > 0 && v_ref >= lw.high_wind_speed) || return nothing + lw.high_wind_l_tether > 0 || + error("low_wind: high_wind_speed is set but high_wind_l_tether is not.") + return lw.high_wind_l_tether +end + """ low_wind_reference(fcs::FC_Settings, project_set) -> Float64 @@ -43,7 +61,8 @@ low_wind_reference(fcs::FC_Settings, project_set) = 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), or only the length with +[`high_wind_l_tether`](@ref) in strong wind. 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 +82,16 @@ 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) + l_high = high_wind_l_tether(fcs, v_ref) + isnothing(l_high) && return (; v_ref, values) + kept = :l_tether in keep || :l_tethers in keep + kept || (project_set.l_tether = l_high) + @info @sprintf("High-wind start at %.2f m/s (%.0f m): l_tether = %.1f m%s.", + v_ref, lw.low_wind_height, l_high, kept ? " (override kept)" : "") + return (; v_ref, values = (; l_tether = l_high)) + 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/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 3651dfb..17bb232 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1158,6 +1158,17 @@ 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 + # high_wind_l_tether: off at 0.0, the length from the threshold on, an error without it. + @test isnothing(high_wind_l_tether(flw, 30.0)) + flw.low_wind.high_wind_speed = 16.0 + @test_throws ErrorException high_wind_l_tether(flw, 16.0) + flw.low_wind.high_wind_l_tether = 180.0 + @test isnothing(high_wind_l_tether(flw, 15.9)) + @test high_wind_l_tether(flw, 16.0) == 180.0 + # The shipped threshold: Cabauw 10 m/s (19.3 at 100 m) starts at 180 m, Cabauw 7 + # (13.5) and Maasvlakte 10 (12.9) at the files' 150 m (2026-11-08). + @test high_wind_l_tether(fro, 19.3) == 180.0 + @test isnothing(high_wind_l_tether(fro, 13.5)) 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 From f2187233f85a6f54545e01350e65bd6d67f0da06 Mon Sep 17 00:00:00 2001 From: ocayon Date: Tue, 10 Nov 2026 11:20:43 +0100 Subject: [PATCH 3/3] Send the KCU's steering limits to the optimizer; depower seed band from 1.7 m Every /init now carries steering_limits {max, rate}: the course controller's max_steering and the steering tape's v_steering of the run's settings (0.32 and 0.2 1/s on the V3), so AWETrim plans no turn the KCU cannot fly. Its own box is the ROM's, 0.357 and 0.29 1/s. V3Kite's rel_steering and the ROM's u_s are the same unit (tape half-difference over 1.4 m). Left out of the request key while unset, so the cached entries stay valid. DEPOWER_SEED_BOUNDS follows the ROM's band, now 1.7-2.6 m: the 2025 flights stall already at 1.7 m of power tape. Generated with Claude Code --- examples/reelout_results.jl | 3 ++- src/awetrim_client.jl | 36 +++++++++++++++++++++++++++++++----- src/opt_request.jl | 17 ++++++++++++++--- src/reelout_loop.jl | 10 ++++++---- src/run_setup.jl | 7 ++++++- src/startup_path.jl | 5 +++-- test/test_awetrim_client.jl | 7 ++++++- test/test_opt_request.jl | 10 +++++----- test/test_reopt_chain.jl | 1 + 9 files changed, 74 insertions(+), 22 deletions(-) diff --git a/examples/reelout_results.jl b/examples/reelout_results.jl index 15d3bb6..bf8e265 100644 --- a/examples/reelout_results.jl +++ b/examples/reelout_results.jl @@ -191,7 +191,8 @@ function power_comparison(setup, st::RunState, sl, rp, p4) lengths_ro = Float64.(sl.var_10[rp.idx]) fs_ref = free_speed_reference(tos, setup.rcs, setup.inflow, setup.guess_az, setup.guess_el, lengths_ro; min_turn_radius = st.opt_r_min, - pattern_limits = setup.opt_box) + pattern_limits = setup.opt_box, + steering_limits = setup.opt_steering) if isnothing(fs_ref) @warn "free_speed reference: no solve succeeded, omitting it from the summary." else diff --git a/src/awetrim_client.jl b/src/awetrim_client.jl index 695d859..0263548 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -77,6 +77,21 @@ Base.@kwdef struct DepowerSpec value::Union{Float64, Nothing} = nothing # fixed value, or the starting one end +""" + SteeringLimits(; max = nothing, rate = nothing) + +The kite's steering actuator as the optimizer must respect it: `|u_s| <= max` and +`|d u_s / dt| <= rate` [1/s] at every node, in the ROM's steering unit, the +steering-tape half-difference over 1.4 m, which is V3Kite's `rel_steering` too +(`V3_STEERING_GAIN`). The server only NARROWS its own box with them (LEI-V3 ROM: +0.357 and 0.29 1/s), so a path it returns is one the KCU can fly. Built from the +run's settings by [`steering_limits_from`](@ref). +""" +Base.@kwdef struct SteeringLimits + max::Union{Float64, Nothing} = nothing + rate::Union{Float64, Nothing} = nothing +end + """ DepowerReply @@ -120,6 +135,8 @@ 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 + # The KCU's steering range and rate; the session keeps them for every step. + steering_limits::Union{SteeringLimits, Nothing} = nothing end Base.@kwdef struct StepParams @@ -201,6 +218,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{SteeringLimits}) = StructTypes.Struct() +StructTypes.omitempties(::Type{SteeringLimits}) = (:max, :rate) +StructTypes.omitempties(::Type{InitParams}) = (:steering_limits,) # --------------------------------------------------------------------------- # Failed-request cache @@ -255,8 +275,12 @@ 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) + # `steering_limits` came last and is left out while unset, so the keys cached before it + # stay valid (as `PatternLimits`' `symmetric`). return "v4-" * stable_hash(Tuple(_key_fields(getfield(p, f)) - for f in fieldnames(InitParams) if f !== :name)) + for f in fieldnames(InitParams) + if f !== :name && + !(f === :steering_limits && isnothing(p.steering_limits)))) end """ @@ -276,7 +300,7 @@ stable_hash(x) = bytes2hex(sha256(repr(x)))[1:16] # 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, - StepParams}) = + SteeringLimits, 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 # before it stay valid. @@ -1413,7 +1437,8 @@ end """ free_speed_reference(tos, wc, inflow, guess_az, guess_el, lengths; - min_turn_radius = nothing, pattern_limits = nothing) + min_turn_radius = nothing, pattern_limits = nothing, + steering_limits = nothing) -> (; points, weighted) | nothing Mean reel-out power an OPTIMAL path could harvest with any winch inside @@ -1435,7 +1460,8 @@ counts stay its own. A converged probe is stored as if applied: each is a cold chain of one step, and it IS the result the caller uses. """ function free_speed_reference(tos, wc, inflow, guess_az, guess_el, lengths; - min_turn_radius = nothing, pattern_limits = nothing) + min_turn_radius = nothing, pattern_limits = nothing, + steering_limits = nothing) n = tos.server.free_speed_reference_points (n < 2 || isempty(lengths)) && return nothing lo, hi = extrema(lengths) @@ -1454,7 +1480,7 @@ 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, steering_limits) 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/opt_request.jl b/src/opt_request.jl index bcce02f..5416a45 100644 --- a/src/opt_request.jl +++ b/src/opt_request.jl @@ -33,9 +33,9 @@ function awetrim_depower_to_v3kite(l_dp, tos) end "The ROM's depower band, `validity.input_depower` of AWETrim's -`rom_config_aerostructural.yaml` (2026-11-05 identification), to which the server clips -its `input_depower` box [m]." -const DEPOWER_SEED_BOUNDS = (1.5, 2.6) +`rom_config_aerostructural.yaml`, to which the server clips its `input_depower` box [m]. +Its powered end is 1.7 m since 2026-11-10: the 2025 flights stall already there." +const DEPOWER_SEED_BOUNDS = (1.7, 2.6) """ depower_seed(tos, wind_speed) -> Float64 @@ -197,6 +197,17 @@ function min_turn_radius_request(fcs, tos; scale = 1.0, c1 = nothing, return scale * margin / (coeffs.c1 * fcs.course.max_steering) end +""" + steering_limits_from(fcs, set) -> SteeringLimits + +The KCU as the optimizer must respect it: the course controller's command limit +`fcs.course.max_steering` and the steering tape's rate limit `set.v_steering` [1/s] of +the run's settings. Sent with every `/init`, so the server plans no turn the KCU cannot +fly; its own box is the ROM's (0.357, 0.29 1/s on the LEI-V3). +""" +steering_limits_from(fcs, set) = + SteeringLimits(; max = Float64(fcs.course.max_steering), rate = Float64(set.v_steering)) + """ request_constraints(tos, fcs, inflow, cap_wind, l_opt) -> (; turn_radius_reel, opt_r_scale, depower_request, c1_request, opt_r_min, opt_r_on, diff --git a/src/reelout_loop.jl b/src/reelout_loop.jl index 224cd89..2924cc3 100644 --- a/src/reelout_loop.jl +++ b/src/reelout_loop.jl @@ -271,7 +271,7 @@ the simulation until the solve is over and retries a failed one as a cold `/init """ function request_reopt!(st::RunState, setup, t, phase, l_now) (; tos, fcs, opt_chain, opt_r_on, c1_at_phase, cap_wind, el_center_seed, winch_reopt, - inflow) = setup + inflow, opt_steering) = setup try # Clocked from here, so a request that fails while being BUILT still has a start. st.reopt_t_wall_request = time() @@ -317,7 +317,8 @@ 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, + steering_limits = opt_steering) # 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) @@ -584,7 +585,7 @@ into `box`, under `st.opt_r_min`. Holds the simulation until the solve is over; optimizer's state. """ function solve_cold!(st::RunState, setup, l_now, el_seed, box) - (; tos, opt_chain, winch_reopt, inflow) = setup + (; tos, opt_chain, winch_reopt, inflow, opt_steering) = setup # Started inside the size box, as in `request_reopt!`. guess_a, guess_b, guess_el_center = guess_in_box(tos.guess.guess_a, tos.guess.guess_b, el_seed, box) guess_az, guess_el = figure_eight_path(guess_a, guess_b, 0.0, guess_el_center, 0.0, @@ -596,7 +597,8 @@ 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, + steering_limits = opt_steering) 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_setup.jl b/src/run_setup.jl index 4abbf06..fc4d623 100644 --- a/src/run_setup.jl +++ b/src/run_setup.jl @@ -119,6 +119,8 @@ Base.@kwdef mutable struct RunSetup{S, CD, CC} opt_r_sent::Union{Nothing, Float64} "The pattern box sent; nothing when off" opt_box::Union{Nothing, PatternLimits} + "The KCU's steering range and rate, sent with every `/init`" + opt_steering::SteeringLimits "Every reply's depower" opt_depower_log::Vector{NamedTuple} # ---- the laws the gates and the loop read ---- @@ -302,6 +304,9 @@ function setup_run(inputs; init_model) # Constraints the solve must respect; the turn radius carries the anchor ratio `L/r` and the gate's headroom. (; opt_r_scale, opt_r_min, opt_r_on, opt_r_sent, opt_box) = request_constraints(tos, fcs, inflow, cap_wind, opt_length(l_set)) + opt_steering = steering_limits_from(fcs, project_set) + @info @sprintf("Steering limits sent with the request: |u_s| <= %.3f, rate <= %.3f 1/s.", + opt_steering.max, opt_steering.rate) # `startup_params` fits the guess into that box; a guess outside it ends in local infeasibility. let (guess_a, guess_b, guess_el) = guess_in_box(tos.guess.guess_a, tos.guess.guess_b, el_center_seed, opt_box) (guess_a, guess_b, guess_el) == (tos.guess.guess_a, tos.guess.guess_b, el_center_seed) || @@ -369,7 +374,7 @@ function setup_run(inputs; init_model) output_path, run_done_file, log_name, wc, wpc, dt0, rcs, s, rc, f_high_nominal, guard_lfc, l_set, fec, inflow, cap_wind, winch, winch_first_lap, winch_reopt, el_center_seed_base, el_center_seed, opt_chain, opt_r_scale, opt_r_min, opt_r_on, opt_r_sent, opt_box, - opt_depower_log, power_gate_off, wing_lift, c1_at_depower, c1_ctrl_at, + opt_steering, opt_depower_log, power_gate_off, wing_lift, c1_at_depower, c1_ctrl_at, c1_setpoint, c1_depower_max, pattern_depower, el_floor) end diff --git a/src/startup_path.jl b/src/startup_path.jl index f14de6c..22b068b 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -21,7 +21,7 @@ cold re-optimizations are; everything else comes from `tos`, the inflow and the first-lap winch. """ function startup_params(setup, el_center) - (; tos, l_set, winch_first_lap, inflow, opt_r_min, opt_box) = setup + (; tos, l_set, winch_first_lap, inflow, opt_r_min, opt_box, opt_steering) = setup guess_a, guess_b, guess_el = guess_in_box(tos.guess.guess_a, tos.guess.guess_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), @@ -29,7 +29,8 @@ function startup_params(setup, el_center) 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, + steering_limits = opt_steering) end """ diff --git a/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index d73d375..8653140 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -12,7 +12,7 @@ leaves it, without sending the `/init`. using Test using RobustKiteControllers import RobustKiteControllers: InflowConditions, WinchParams, Trajectory, InitParams, StepParams, - PatternLimits, DepowerSpec, opt_request_key, stable_hash, chain_key, _key_fields, + PatternLimits, DepowerSpec, SteeringLimits, opt_request_key, stable_hash, chain_key, _key_fields, 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, @@ -59,6 +59,7 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n params(; min_turn_radius = 12.0), params(; depower = DepowerSpec()), params(; input_depower = 1.7), params(; pattern_limits = PatternLimits(; azimuth_max = 28.0)), + params(; steering_limits = SteeringLimits(; max = 0.32, rate = 0.2)), params(; winch_params = WinchParams(; mode = "reelout", k_v = 0.04, f_min = 700.0, f_max = 7200.0, softplus_beta = 0.03)), @@ -68,6 +69,10 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n @test opt_request_key(q) != k end @test opt_request_key(params(; length = -0.0)) != opt_request_key(params(; length = 0.0)) + # Sent as the server's SteeringLimits, unset fields left out. + js = JSON3.read(JSON3.write(params(; steering_limits = SteeringLimits(; max = 0.32)))) + @test js.steering_limits.max == 0.32 && !haskey(js.steering_limits, :rate) + @test !haskey(JSON3.read(JSON3.write(params())), :steering_limits) @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. @test length(_key_fields(PatternLimits(; azimuth_max = 28.0))) == 5 diff --git a/test/test_opt_request.jl b/test/test_opt_request.jl index 6b9b92e..bac11ec 100644 --- a/test/test_opt_request.jl +++ b/test/test_opt_request.jl @@ -33,14 +33,14 @@ set_depower_conversion!(l_dp -> (l_dp - 0.2) / 5) @testset "depower_seed" begin lo, hi = DEPOWER_SEED_BOUNDS - tos = TrajOptSettings(; input_depower = 1.6, input_depower_per_wind = 0.1, input_depower_wind_ref = 7.0, + tos = TrajOptSettings(; input_depower = 1.8, input_depower_per_wind = 0.1, input_depower_wind_ref = 7.0, input_depower_seed_max = 0.0) # One-sided: flat below the reference wind, a ramp above it. - @test depower_seed(tos, 5.0) == 1.6 - @test depower_seed(tos, 9.0) ≈ 1.8 + @test depower_seed(tos, 5.0) == 1.8 + @test depower_seed(tos, 9.0) ≈ 2.0 # The soft cap clamps short of AWETrim's own bound, silently: it is calibrated. - capped = TrajOptSettings(tos; input_depower_seed_max = 1.7) - @test (@test_logs depower_seed(capped, 12.0)) == 1.7 + capped = TrajOptSettings(tos; input_depower_seed_max = 1.9) + @test (@test_logs depower_seed(capped, 12.0)) == 1.9 # AWETrim's hard bound clamps too, and warns: the ramp has run out of tape. @test (@test_logs (:warn,) depower_seed(tos, 20.0)) == hi @test (@test_logs (:warn,) depower_seed(TrajOptSettings(tos; input_depower = 0.5), 5.0)) == lo diff --git a/test/test_reopt_chain.jl b/test/test_reopt_chain.jl index 9944a2b..1a6b16b 100644 --- a/test/test_reopt_chain.jl +++ b/test/test_reopt_chain.jl @@ -69,6 +69,7 @@ RobustKiteControllers.set_depower_conversion!(l_dp -> (l_dp - 0.2) / 5) feas = (; c1 = NaN), el_floor = 13.0, wing_lift = (az, el) -> zero(az), power_gate_off = pred -> false, margin5 = Phase5MarginState(), phase5_margin_at = (az, el) -> 1.7, opt_depower_log = NamedTuple[], + opt_steering = RobustKiteControllers.SteeringLimits(; max = 0.32, rate = 0.2), project_set = (; v_wind = 8.0), wc = nothing, rc = nothing) st = RunState(; n_path = length(fec.az_path), fig8_idx_progress = length(fec.az_path), opt_paths_raw = [flown], opt_paths_at = [(0.0, 0)], opt_power_pred = power,