From 5336847fe29fbc37da832d778b324f27e7302ffb Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:21:46 +0100 Subject: [PATCH 01/39] Lift/drag validation of V3Kite against the AWETrim ROM examples/validate_lift_drag.jl parks the kite at 150 m, trims the ground wind per block until v_a reaches its target, averages V3Kite's lift, drag and span-mean AoA, and queries AWETrim's POST /aero_forces (both LEI-V3 ROMs, at the ROM trim and at V3Kite's AoA). Saved to output/lift_drag_validation.json; fly = false re-queries without flying. examples/show_validation.jl shows it as an HTML page in the browser. Both are in the identification menu and on its docs page. Co-Authored-By: Claude Opus 5.5 --- docs/src/examples_identification.md | 14 ++ examples/menu2.jl | 2 + examples/show_validation.jl | 149 ++++++++++++++++++++ examples/validate_lift_drag.jl | 207 ++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 examples/show_validation.jl create mode 100644 examples/validate_lift_drag.jl diff --git a/docs/src/examples_identification.md b/docs/src/examples_identification.md index 3cbee728..560f8950 100644 --- a/docs/src/examples_identification.md +++ b/docs/src/examples_identification.md @@ -165,3 +165,17 @@ 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, for both LEI-V3 ROMs, at the ROM's own trim and at V3Kite's angle +of attack. 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: one table per ROM, with the +deviation from V3Kite in percent and the points outside the ROM's validity band marked. diff --git a/examples/menu2.jl b/examples/menu2.jl index 626d2005..39db1d41 100644 --- a/examples/menu2.jl +++ b/examples/menu2.jl @@ -41,6 +41,8 @@ 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", ] """ diff --git a/examples/show_validation.jl b/examples/show_validation.jl new file mode 100644 index 00000000..242be9fb --- /dev/null +++ b/examples/show_validation.jl @@ -0,0 +1,149 @@ +# 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 at V3Kite's angle of +attack, and the deviation of the latter from V3Kite in percent. Rows outside the ROM's +validity band are marked. + + include("examples/show_validation.jl") +""" + +using Pkg +if Base.active_project() != joinpath(@__DIR__, "Project.toml") + Pkg.activate(joinpath(@__DIR__)) +end + +using JSON3 +using Printf + +"The results `validate_lift_drag.jl` writes" +const RESULT_FILE = normpath(joinpath(@__DIR__, "..", "output", "lift_drag_validation.json")) +# A snap-confined browser cannot read dotdirs under $HOME, so the page goes to $HOME itself. +"The page written and opened" +const HTML_FILE = joinpath(homedir(), "lift_drag_validation.html") + +const CSS = """ +:root { --bg:#fff; --fg:#1d1d1f; --muted:#6e6e73; --line:#d9d9de; --head:#f3f3f6; + --good:#1a7f37; --warn:#b26a00; --bad:#c62828; } +@media (prefers-color-scheme: dark) { + :root { --bg:#16161a; --fg:#ececf1; --muted:#9a9aa3; --line:#34343c; --head:#202027; + --good:#4cc26a; --warn:#e0a03a; --bad:#ef6b6b; } } +body { background:var(--bg); color:var(--fg); margin:0 auto; max-width:1100px; padding:24px 16px; + font:15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } +h1 { font-size:22px; margin:0 0 4px; } h2 { font-size:17px; margin:28px 0 8px; } +.meta { color:var(--muted); font-size:13px; } +.wrap { overflow-x:auto; } +table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; } +th, td { padding:6px 10px; border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; } +thead th { background:var(--head); font-weight:600; } +thead tr:first-child th { text-align:center; } +th.grp { border-left:2px solid var(--line); } td.grp { border-left:2px solid var(--line); } +.good { color:var(--good); } .warn { color:var(--warn); } .bad { color:var(--bad); font-weight:600; } +.out { color:var(--bad); } +ul { color:var(--muted); font-size:13px; padding-left:18px; } +""" + +esc(x) = replace(string(x), "&" => "&", "<" => "<", ">" => ">") +num(x, d = 0) = x isa Number ? @sprintf("%.*f", d, x) : "–" +ld(r) = haskey(r, "error") ? "error" : + "$(num(r["lift"])) / $(num(r["drag"]))" + +"Deviation of `x` from the V3Kite value `ref` in percent, coloured by size" +function deviation(x, ref) + (x isa Number && ref isa Number && ref != 0) || return "–" + d = 100 * (x - ref) / ref + cls = abs(d) < 5 ? "good" : abs(d) < 15 ? "warn" : "bad" + return "$(@sprintf("%+.0f", d)) %" +end + +""" + rom_table(io, label, v3kite, awetrim) + +One table: the V3Kite blocks against the ROM `label`. +""" +function rom_table(io, label, v3kite, awetrim) + first_ok = something(findfirst(aw -> !haskey(aw[label]["trim"], "error"), awetrim), 0) + rom_file = first_ok == 0 ? "" : basename(string(awetrim[first_ok][label]["trim"]["rom_config"])) + println(io, "

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

") + println(io, """
+ + + + + + + + """) + for (res, aw) in zip(v3kite, awetrim) + trim, at = aw[label]["trim"], aw[label]["v3kite_aoa"] + valid = get(at, "within_validity", nothing) + band = get(at, "input_depower_validity", nothing) + valid_cell = valid === nothing ? "–" : valid ? "yes" : + "no" + println(io, "", + "", + "", + "", + "", + "", + "") + end + println(io, "
V3KiteAWETrim, ROM trimAWETrim at V3Kite AoA
tape [m]va [m/s]AoA [°]L / D [N]AoA [°]L / D [N]L / D [N]ΔLΔDin ROM band
$(num(res["tape"], 2))$(num(res["v_app"], 1))$(num(res["aoa"], 2))$(num(res["lift"])) / $(num(res["drag"]))$(num(get(trim, "angle_of_attack", nothing), 2))$(ld(trim))$(ld(at))$(deviation(get(at, "lift", nothing), res["lift"]))$(deviation(get(at, "drag", nothing), res["drag"]))$valid_cell
") +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.

""") + for label in data["roms"] + rom_table(io, label, data["v3kite"], data["awetrim"]) + end + println(io, """""") + end + return file +end + +""" + open_in_browser(file) + +Open `file` in the default browser on Linux, Windows or macOS; print its path if that fails. +""" +function open_in_browser(file) + cmd = Sys.iswindows() ? `cmd /c start "" $file` : Sys.isapple() ? `open $file` : `xdg-open $file` + try + run(pipeline(cmd; stdout = devnull, stderr = devnull); wait = false) + catch + println("Could not open a browser; open $file manually.") + end + nothing +end + +isfile(RESULT_FILE) || error("No $RESULT_FILE; run examples/validate_lift_drag.jl first.") +data = JSON3.read(read(RESULT_FILE, String), Dict{String, Any}) +@info "show_validation: wrote $(write_validation_html(data, HTML_FILE))" +open_in_browser(HTML_FILE) +nothing diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl new file mode 100644 index 00000000..de044a44 --- /dev/null +++ b/examples/validate_lift_drag.jl @@ -0,0 +1,207 @@ +# 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, tether + bridle + KCU drag, `span_mean_aoa` and `v_a` are averaged. Lift and drag +are V3Kite's `lift_drag`: the wing's force perpendicular to and along its apparent wind. + +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 each ROM of `ROMS`, once at the ROM's own gravity-free trim and once at +V3Kite's angle of attack. The two angles are not defined identically (V3Kite: span mean +of the VSM panels' geometric angle; ROM: the wing angle of its identification), so both +are kept. AWETrim's wing drag includes the bridle lines; V3Kite has them in the tether drag. + +`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 +using V3Kite +using V3Kite: init, step! +using DiscretePIDs: set_K! +using HTTP, JSON3 +using OrderedCollections: OrderedDict +using Statistics +using Printf +import Dates +include(joinpath(@__DIR__, "winch_adapter.jl")) # winch_torque!: V3Kite is torque-only + +"Where the results are saved" +const RESULT_FILE = normpath(joinpath(@__DIR__, "..", "output", "lift_drag_validation.json")) +"The ROMs queried: label => `rom_config_path` (`nothing` = the system file's default)" +const ROMS = ["flight_corrected" => nothing, "aerostructural" => "rom_config_aerostructural.yaml"] +"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.58, 15.3), (1.58, 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"; readtimeout = 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 an AWETrim with it \ + and restart the server (bin/run_server restart).") + nothing +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). +""" +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, :tether_drag, :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) + push!(acc[:v_app], s.sys_state.v_app) + push!(acc[:lift], lift) + push!(acc[:drag], drag) + push!(acc[:tether_drag], total_drag(s)[2]) + 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 AoA %5.2f° wind %5.2f m/s\n", + tape, res["v_app"], v_target, res["lift"], res["drag"], res["aoa"], res["v_wind"]) + push!(results, res) + end + return results +end + +""" + aero_forces(url, tape, v_app; alpha = nothing, rom = nothing) -> Dict + +One `POST /aero_forces`; `alpha` in degrees (`nothing` = the ROM's trim). A request +the server rejects returns `Dict("error" => detail)` instead of throwing. +""" +function aero_forces(url, tape, v_app; alpha = nothing, rom = nothing) + req = Dict{String, Any}("input_depower" => tape, "speed_apparent_wind" => v_app) + isnothing(alpha) || (req["angle_of_attack"] = alpha) + 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`) and at V3Kite's angle of attack (`v3kite_aoa`). +""" +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), + "v3kite_aoa" => aero_forces(url, res["tape"], res["v_app"]; rom, alpha = res["aoa"])) + 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, at_aoa = aw[label]["trim"], aw[label]["v3kite_aoa"] + 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°) at V3Kite AoA %s N\n", + label, res["tape"], res["v_app"], fmt(trim), get(trim, "angle_of_attack", NaN), fmt(at_aoa)) +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 From 50f051d2b5a491e8edbdbecb5f12dcc9d478d0f7 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:26:18 +0100 Subject: [PATCH 02/39] validate_lift_drag: request_timeout, name the AWETrim branch in the error Co-Authored-By: Claude Opus 5.5 --- examples/validate_lift_drag.jl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index de044a44..7339bc3a 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -71,13 +71,14 @@ Fail before flying when the AWETrim server at `url` does not answer or has no """ function check_aero_forces_endpoint(url) spec = try - String(HTTP.get(url * "/openapi.json"; readtimeout = 5).body) + 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 an AWETrim with it \ - and restart the server (bin/run_server restart).") + 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 From 2ed153097b1396131d833dc02fd4cdb1b35f7788 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:28:03 +0100 Subject: [PATCH 03/39] install_awetrim: install the feat/server-aero-forces branch It carries the POST /aero_forces endpoint validate_lift_drag.jl needs. A branch only in the local checkout (not pushed yet) is checked out as it is. Co-Authored-By: Claude Opus 5.5 --- bin/install_awetrim | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/bin/install_awetrim b/bin/install_awetrim index 2675730e..d3f9d3fb 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." From cbca9ca543f5fb4275a1ea7be6d512acd66d36db Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:34:40 +0100 Subject: [PATCH 04/39] validate_lift_drag: info message at the start Co-Authored-By: Claude Opus 5.5 --- examples/validate_lift_drag.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index 7339bc3a..791f9183 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -45,6 +45,9 @@ 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` (`nothing` = the system file's default)" From 0022e7de4fc6393413c826c30e7e5479391dcc44 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:37:17 +0100 Subject: [PATCH 05/39] JETLS: no type inlay hints, from .JETLSConfig.toml.default Co-Authored-By: Claude Opus 5.5 --- .JETLSConfig.toml.default | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.JETLSConfig.toml.default b/.JETLSConfig.toml.default index 31945eb3..ae4d9c23 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 From 8e7418c7a7ae32c65e34ec471adf3ac0b9390d18 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Tue, 27 Oct 2026 18:45:04 +0100 Subject: [PATCH 06/39] validate_lift_drag: compare drag on AWETrim's basis, VSM wing + all bridle lines lift_drag's drag holds only the bridle share SymbolicAWEModels lumps onto the wing nodes (about 30 % of it), while the ROM's C_D includes every bridle line. drag_parts splits the drag into VSM wing, bridle, tether and KCU; the old lift_drag value is kept as drag_lift_drag. show_validation.jl adds a table of the split. Co-Authored-By: Claude Opus 5.5 --- examples/show_validation.jl | 30 +++++++++++++- examples/validate_lift_drag.jl | 74 +++++++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/examples/show_validation.jl b/examples/show_validation.jl index 242be9fb..cf88b83a 100644 --- a/examples/show_validation.jl +++ b/examples/show_validation.jl @@ -95,6 +95,29 @@ function rom_table(io, label, v3kite, awetrim) 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

+ + + + """) + for res in v3kite + println(io, "", + "", + "", + "", + "") + end + println(io, "
tape [m]va [m/s]VSM wing [N]bridle lines [N]compared D [N]bridle in lift_drag [N]lift_drag D [N]tether [N]KCU [N]
$(num(res["tape"], 2))$(num(res["v_app"], 1))$(num(res["drag_vsm"]))$(num(res["drag_bridle"]))$(num(res["drag"]))$(num(res["drag_bridle_in_wing"]))$(num(res["drag_lift_drag"]))$(num(res["drag_tether"]))$(num(res["drag_kcu"]))
") +end + """ write_validation_html(data, file) @@ -115,9 +138,12 @@ function write_validation_html(data, file) for label in data["roms"] rom_table(io, label, data["v3kite"], data["awetrim"]) end + drag_table(io, data["v3kite"]) println(io, """
    -
  • V3Kite L / D: lift_drag, the wing's force perpendicular to and along - its apparent wind; tether, bridle and KCU drag are not in D.
  • +
  • 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 at diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index 791f9183..0589009c 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -11,15 +11,18 @@ winch length loop) and flies one block per point of `points`, `(tape, v_a)` with 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, tether + bridle + KCU drag, `span_mean_aoa` and `v_a` are averaged. Lift and drag -are V3Kite's `lift_drag`: the wing's force perpendicular to and along its apparent wind. +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 each ROM of `ROMS`, once at the ROM's own gravity-free trim and once at V3Kite's angle of attack. The two angles are not defined identically (V3Kite: span mean of the VSM panels' geometric angle; ROM: the wing angle of its identification), so both -are kept. AWETrim's wing drag includes the bridle lines; V3Kite has them in the tether drag. +are kept. `fly = false` re-queries AWETrim for the V3Kite results already saved, without flying: @@ -40,6 +43,8 @@ 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 @@ -85,11 +90,54 @@ function check_aero_forces_endpoint(url) 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). +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, @@ -108,8 +156,9 @@ function fly_points(points; t_trim, t_avg) 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, :tether_drag, :aoa, - :elevation, :force, :v_wind)) + 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) @@ -124,10 +173,14 @@ function fly_points(points; t_trim, t_avg) 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], drag) - push!(acc[:tether_drag], total_drag(s)[2]) + 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)) @@ -140,8 +193,9 @@ function fly_points(points; t_trim, t_avg) 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 AoA %5.2f° wind %5.2f m/s\n", - tape, res["v_app"], v_target, res["lift"], res["drag"], res["aoa"], res["v_wind"]) + @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 From 8fabed5f94711ee71efe380aab36eb03c30c6b1e Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 10:35:37 +0100 Subject: [PATCH 07/39] Fly every AWETrim request against rom_config_aerostructural2.yaml AWETRIM_ROM names the ROM; InitParams sends it as rom_config_path (so it is in the request and cache keys), opt_init refuses a reply whose rom_config names another file (check_rom), and validate_lift_drag.jl queries only it. The test fake server echoes rom_config like AWETrim; the pinned keys move. Co-Authored-By: Claude Opus 5.5 --- examples/validate_lift_drag.jl | 11 ++++++----- src/awetrim_client.jl | 31 ++++++++++++++++++++++++++++--- test/fake_awetrim_server.jl | 3 ++- test/test_awetrim_client.jl | 17 ++++++++++++++--- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index 0589009c..54c62a49 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -19,8 +19,8 @@ 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 each ROM of `ROMS`, once at the ROM's own gravity-free trim and once at -V3Kite's angle of attack. The two angles are not defined identically (V3Kite: span mean +block, for the ROM `AWETRIM_ROM` (`src/awetrim_client.jl`), once at the ROM's own +gravity-free trim and once at V3Kite's angle of attack. The two angles are not defined identically (V3Kite: span mean of the VSM panels' geometric angle; ROM: the wing angle of its identification), so both are kept. @@ -37,7 +37,8 @@ if Base.active_project() != joinpath(@__DIR__, "Project.toml") end using RobustKiteControllers -using RobustKiteControllers: run_example, script_inputs, package_git_state, AWETRIM_URL +using RobustKiteControllers: run_example, script_inputs, package_git_state, AWETRIM_URL, + AWETRIM_ROM using V3Kite using V3Kite: init, step! using DiscretePIDs: set_K! @@ -55,8 +56,8 @@ include(joinpath(@__DIR__, "winch_adapter.jl")) # winch_torque!: V3Kite is torq "Where the results are saved" const RESULT_FILE = normpath(joinpath(@__DIR__, "..", "output", "lift_drag_validation.json")) -"The ROMs queried: label => `rom_config_path` (`nothing` = the system file's default)" -const ROMS = ["flight_corrected" => nothing, "aerostructural" => "rom_config_aerostructural.yaml"] +"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], diff --git a/src/awetrim_client.jl b/src/awetrim_client.jl index 695d8598..0e9f94ec 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -53,6 +53,13 @@ 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 shared structs (as agreed) @@ -120,6 +127,7 @@ 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 end Base.@kwdef struct StepParams @@ -171,6 +179,7 @@ struct InitReply pattern_limits::Union{PatternLimits, Nothing} state::String n_points::Union{Int, Nothing} + rom_config::Union{String, Nothing} end """ @@ -574,7 +583,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 +611,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 """ diff --git a/test/fake_awetrim_server.jl b/test/fake_awetrim_server.jl index aef57f45..4660c24d 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 d73d3758..2dab4818 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,6 +58,7 @@ 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(; winch_params = WinchParams(; mode = "reelout", k_v = 0.04, f_min = 700.0, f_max = 7200.0, @@ -146,7 +147,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, @@ -294,6 +295,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 From a992f617acadc9252eb0af675de5f66679b6a965 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 10:45:13 +0100 Subject: [PATCH 08/39] validate_lift_drag: only the ROM trim, no query at V3Kite's angle of attack show_validation.jl takes the deviation from the ROM trim instead. Co-Authored-By: Claude Opus 5.5 --- docs/src/examples_identification.md | 8 ++++---- examples/show_validation.jl | 27 ++++++++++++--------------- examples/validate_lift_drag.jl | 26 +++++++++++--------------- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/docs/src/examples_identification.md b/docs/src/examples_identification.md index 560f8950..a932e949 100644 --- a/docs/src/examples_identification.md +++ b/docs/src/examples_identification.md @@ -172,10 +172,10 @@ without flying. This analysis is work in progress. 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, for both LEI-V3 ROMs, at the ROM's own trim and at V3Kite's angle -of attack. The results go to `output/lift_drag_validation.json`; `fly = false` re-queries AWETrim +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: one table per ROM, with the -deviation from V3Kite in percent and the points outside the ROM's validity band marked. +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. diff --git a/examples/show_validation.jl b/examples/show_validation.jl index cf88b83a..00456787 100644 --- a/examples/show_validation.jl +++ b/examples/show_validation.jl @@ -5,9 +5,8 @@ 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 at V3Kite's angle of -attack, and the deviation of the latter from V3Kite in percent. Rows outside the ROM's -validity band are marked. +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") """ @@ -71,25 +70,23 @@ function rom_table(io, label, v3kite, awetrim) println(io, "

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

    ") println(io, """
    - - + - - + + """) for (res, aw) in zip(v3kite, awetrim) - trim, at = aw[label]["trim"], aw[label]["v3kite_aoa"] - valid = get(at, "within_validity", nothing) - band = get(at, "input_depower_validity", nothing) + trim = aw[label]["trim"] + valid = get(trim, "within_validity", nothing) + band = get(trim, "input_depower_validity", nothing) valid_cell = valid === nothing ? "–" : valid ? "yes" : "no" println(io, "", "", "", - "", - "", - "", + "", + "", "") end println(io, "
    V3KiteAWETrim, ROM trimAWETrim at V3Kite AoA
    AWETrim, ROM trim
    tape [m]va [m/s] AoA [°]L / D [N]AoA [°]L / D [N]L / D [N]ΔLΔDin ROM band
    AoA [°]L / D [N]ΔLΔDin ROM band
    $(num(res["tape"], 2))$(num(res["v_app"], 1))$(num(res["aoa"], 2))$(num(res["lift"])) / $(num(res["drag"]))$(num(get(trim, "angle_of_attack", nothing), 2))$(ld(trim))$(ld(at))$(deviation(get(at, "lift", nothing), res["lift"]))$(deviation(get(at, "drag", nothing), res["drag"]))$(deviation(get(trim, "lift", nothing), res["lift"]))$(deviation(get(trim, "drag", nothing), res["drag"]))$valid_cell
    ") @@ -146,8 +143,8 @@ function write_validation_html(data, file) 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 at - V3Kite's span-mean angle of attack, relative to V3Kite.
  • +
  • ROM trim: the ROM's own gravity-free angle of attack. ΔL, ΔD: AWETrim relative + to V3Kite.
""") end return file diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index 54c62a49..da58f218 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -19,10 +19,8 @@ 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`), once at the ROM's own -gravity-free trim and once at V3Kite's angle of attack. The two angles are not defined identically (V3Kite: span mean -of the VSM panels' geometric angle; ROM: the wing angle of its identification), so both -are kept. +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: @@ -203,14 +201,13 @@ function fly_points(points; t_trim, t_avg) end """ - aero_forces(url, tape, v_app; alpha = nothing, rom = nothing) -> Dict + aero_forces(url, tape, v_app; rom = nothing) -> Dict -One `POST /aero_forces`; `alpha` in degrees (`nothing` = the ROM's trim). A request -the server rejects returns `Dict("error" => detail)` instead of throwing. +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; alpha = nothing, rom = nothing) +function aero_forces(url, tape, v_app; rom = nothing) req = Dict{String, Any}("input_depower" => tape, "speed_apparent_wind" => v_app) - isnothing(alpha) || (req["angle_of_attack"] = alpha) isnothing(rom) || (req["rom_config_path"] = rom) try resp = HTTP.post(url * "/aero_forces", ["Content-Type" => "application/json"]; @@ -226,13 +223,12 @@ 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`) and at V3Kite's angle of attack (`v3kite_aoa`). +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), - "v3kite_aoa" => aero_forces(url, res["tape"], res["v_app"]; rom, alpha = res["aoa"])) + "trim" => aero_forces(url, res["tape"], res["v_app"]; rom)) for (label, rom) in ROMS) end end @@ -250,10 +246,10 @@ end awetrim = query_awetrim(v3kite, url) for (res, aw) in zip(v3kite, awetrim), (label, _) in ROMS - trim, at_aoa = aw[label]["trim"], aw[label]["v3kite_aoa"] + 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°) at V3Kite AoA %s N\n", - label, res["tape"], res["v_app"], fmt(trim), get(trim, "angle_of_attack", NaN), fmt(at_aoa)) + @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)) From b0c6b4c8a7724246b9a8ba7a7f5db8464d7a33e5 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 10:50:38 +0100 Subject: [PATCH 09/39] validate_lift_drag: 1.60 m instead of 1.58 m, inside the ROM's validity band Co-Authored-By: Claude Opus 5.5 --- examples/validate_lift_drag.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/validate_lift_drag.jl b/examples/validate_lift_drag.jl index da58f218..e820a8e7 100644 --- a/examples/validate_lift_drag.jl +++ b/examples/validate_lift_drag.jl @@ -67,7 +67,7 @@ const HEADING = (P = 1.1, I = false, D = 0.15, v_app_ref = 13.1, v_app_min = 5.0 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.58, 15.3), (1.58, 8.8), (1.76, 15.3), (1.76, 8.7)], + (; 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)) """ From 6ffbd437b10808301b830d3392de7a728598032e Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 11:20:16 +0100 Subject: [PATCH 10/39] Note: the lift/drag validation compares two different trim conditions Tuning-log entry with the 1.60/1.76 m results, and a warning on the page show_validation.jl writes. Co-Authored-By: Claude Opus 5.5 --- docs/fig8_tuning_log.md | 23 +++++++++++++++++++++++ examples/show_validation.jl | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 7b2e9ce9..5be464bb 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6455,3 +6455,26 @@ 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. diff --git a/examples/show_validation.jl b/examples/show_validation.jl index 00456787..9b7c8566 100644 --- a/examples/show_validation.jl +++ b/examples/show_validation.jl @@ -131,7 +131,10 @@ function write_validation_html(data, file)

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.

""") + 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 From 5bdb1e5c6b4d575d3c82baaa0d4e272fd1c7000b Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 12:48:17 +0100 Subject: [PATCH 11/39] Depower seed 1.6 -> 1.5 m: the startup solve converges on rom_config_aerostructural2 Co-Authored-By: Claude Opus 5.5 --- data/traj_opt.yaml | 2 +- docs/fig8_tuning_log.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index af0c7576..6ac2b6af 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -20,7 +20,7 @@ traj_opt: startup_retry_el_offsets: [-1.0, 2.0, -2.0] # seeds tried in order on a startup 422; [] never retries [deg] 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_seed_max: 1.85 # ramp ceiling, below the server's 2.3 m [m] diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 5be464bb..cae1453e 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6478,3 +6478,22 @@ V3Kite drag is the VSM wing plus all bridle lines, AWETrim's basis. Agreement wi 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. From 240de8639b4344af7cc8a3c87a04b54d785efc55 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Wed, 28 Oct 2026 13:58:23 +0100 Subject: [PATCH 12/39] Startup on rom_config_aerostructural2: box 36x15, guess 32x14, guess-size retries The new ROM turns ~43 % less (physical for its geometry). Replaying the failed startup requests: a 36 x 15 box with a 32x14 guess starts 5, 6, 8, 8.25 m/s, 30x13 and 34x15 cover 7 m/s; startup_retry_guess_sizes tries them before the elevation offsets. The depower seed is flat at 1.5 m. 3.5 m/s still has no startup path. Co-Authored-By: Claude Opus 5.5 --- data/traj_opt.yaml | 20 +++++++++++++------- docs/fig8_tuning_log.md | 25 +++++++++++++++++++++++++ src/awetrim_client.jl | 29 ++++++++++++++++++++--------- src/startup_path.jl | 16 +++++++++------- src/traj_opt_settings.jl | 2 ++ test/test_awetrim_client.jl | 16 +++++++++++++++- test/test_fig8_controller.jl | 30 +++++++++++++++++------------- 7 files changed, 101 insertions(+), 37 deletions(-) diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index 6ac2b6af..2a745ac0 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -12,17 +12,22 @@ 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 [deg] + startup_retry_guess_sizes: [[30.0, 13.0], [34.0, 15.0]] seed: # depower seed of the solve, ramped with the wind 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,16 +44,17 @@ 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: 36.0 # cap at/above pattern_elevation_amplitude_max_wind_ref: 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_high: 15.0 # cap at/above the ref wind (a step, not a ramp); 13 until 2026-10-28 [deg] pattern_elevation_amplitude_max_wind_ref: 13.2 # 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 diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index cae1453e..30038a26 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6497,3 +6497,28 @@ From 1.6 m IPOPT walks to the depowered side, where this ROM's trim angle of att 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. diff --git a/src/awetrim_client.jl b/src/awetrim_client.jl index 0e9f94ec..346efdc9 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -1349,9 +1349,11 @@ 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, then whole degrees walked +outward. `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. @@ -1370,12 +1372,16 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas last_422 = nothing cached_msg = nothing sent = 0 - budget = 1 + length(tos.guess.startup_retry_el_offsets) + sizes = [(s[1], s[2]) for s in tos.guess.startup_retry_guess_sizes] + offsets = startup_seed_offsets(tos.guess.startup_retry_el_offsets) + seeds = [[(0.0, nothing)]; [(0.0, s) for s in sizes]; [(o, nothing) for o in offsets[2:end]]] + budget = 1 + length(sizes) + length(tos.guess.startup_retry_el_offsets) sent_offsets = Float64[] - for offset in startup_seed_offsets(tos.guess.startup_retry_el_offsets) + sent_sizes = Tuple{Float64, Float64}[] + for (offset, size) in seeds sent < budget || break 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 \ @@ -1388,8 +1394,12 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas continue end sent += 1 - push!(sent_offsets, offset) - sent == 1 || + isnothing(size) ? push!(sent_offsets, offset) : push!(sent_sizes, size) + sent == 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, sent - 1, budget - 1, size...) + sent == 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, @@ -1422,7 +1432,8 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas $(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 \ + "failed too (startup_retry_el_offsets)")$(isempty(sent_sizes) ? "" : + ", and so did the guess sizes $(sent_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. diff --git a/src/startup_path.jl b/src/startup_path.jl index ae136a58..ba8adbd7 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -7,16 +7,17 @@ # 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, @@ -64,7 +65,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 diff --git a/src/traj_opt_settings.jl b/src/traj_opt_settings.jl index c7186a0f..c5f18aea 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -49,6 +49,8 @@ $(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}[] end """ diff --git a/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index 2dab4818..da4d0939 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -229,7 +229,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 @@ -272,6 +272,20 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n 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 diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 3a9baf29..9e9f146c 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1069,8 +1069,11 @@ 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). + @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]] # 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,10 +1083,12 @@ 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. + @test tos.box.pattern_elevation_amplitude_max_high == 15.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_height == 100.0 @@ -1091,8 +1096,9 @@ end # 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 == 36.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 @@ -1194,13 +1200,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 From b946d3e809cd54b797bc9b9ba12e310a78d6698b Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 19:21:23 +0100 Subject: [PATCH 13/39] Add SKIP_WINDS --- examples/build_all_scenarios.jl | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/build_all_scenarios.jl b/examples/build_all_scenarios.jl index cc15ff99..485fb934 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" => [6.0, 8.0], + "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() From 6dbe2f087665ce23463650a49906f39d0c3d6639 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 19:21:36 +0100 Subject: [PATCH 14/39] Update overview --- notebooks/overview_cabauw.md | 8 ++++---- notebooks/overview_maasvlakte.md | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index 1d3c6a38..26aa4e52 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -1,10 +1,10 @@ | 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-30 | 18:40:08 | 4.0 | 1.01 | 243.5 | 6.68 | 3 | 2 | 10667 | 5462 | 15387 | 2440 | 4104 | 5302 | 1.87 | 2.58 | 2.96 | 0.23 | 0.57 | all 10 passed | +| 2026-10-30 | 18:41:47 | 5.0 | 0.99 | 91.9 | 7.09 | 2 | 2 | 21220 | 14357 | 25722 | 4937 | 6369 | 7384 | 2.7 | 3.32 | 3.5 | 0.23 | 0.69 | all 10 passed | +| 2026-10-30 | 18:42:42 | 5.5 | 0.98 | 49.1 | 7.22 | 2 | 2 | 22313 | 15527 | 25383 | 5137 | 6561 | 7255 | 2.81 | 3.39 | 3.5 | 0.25 | 0.73 | all 10 passed | +| 2026-10-30 | 18:43:41 | 5.75 | 0.96 | 52.2 | 6.85 | 2 | 2 | 21097 | 12822 | 24660 | 4549 | 6316 | 7069 | 2.54 | 3.32 | 3.5 | 0.262 | 0.74 | 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 | diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 0e860c37..71730830 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -1,12 +1,12 @@ | 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-28 | 14:09:49 | 4.0 | 0.92 | 147.7 | 5.35 | 3 | 2 | 2989 | 1644 | 5132 | 1176 | 1725 | 2595 | 1.39 | 1.69 | 2.03 | 0.23 | 0.53 | all 10 passed | +| 2026-10-28 | 14:13:00 | 5.0 | 0.97 | 175.9 | 5.98 | 4 | 3 | 6325 | 3372 | 9106 | 1803 | 2888 | 4010 | 1.78 | 2.17 | 2.46 | 0.23 | 0.6 | all 10 passed | +| 2026-10-30 | 17:27:57 | 6.0 | 0.99 | 184.3 | 5.74 | 3 | 3 | 11092 | 5630 | 14764 | 2430 | 4198 | 5185 | 2.04 | 2.62 | 2.91 | 0.23 | 0.68 | all 10 passed | +| 2026-10-30 | 18:19:13 | 7.0 | 0.97 | 39.1 | 5.7 | 3 | 3 | 17858 | 10218 | 22691 | 3858 | 5689 | 6807 | 2.36 | 3.12 | 3.41 | 0.23 | 0.77 | all 10 passed | +| 2026-10-30 | 17:29:06 | 8.0 | 0.94 | 64.2 | 7.22 | 2 | 2 | 19626 | 10197 | 24487 | 3841 | 6024 | 7029 | 2.37 | 3.23 | 3.5 | 0.242 | 0.74 | all 10 passed | +| 2026-10-30 | 18:20:24 | 8.25 | 0.92 | 56.9 | 7.07 | 2 | 2 | 19916 | 8885 | 25264 | 3505 | 6080 | 7287 | 2.29 | 3.24 | 3.5 | 0.258 | 0.84 | 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 | From 6a99b6979e817550e50fc4f30eefc382a8de83a3 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 19:22:21 +0100 Subject: [PATCH 15/39] validate_masses.jl: masses of V3Kite vs. AWETrim's model Builds V3Kite's structure for the gui project (no model compile) and reads AWETrim's system file with AWETrim's own Python: canopy, bridle, KCU, the airborne total, the wing mass the trim uses and the tether mass per metre. V3Kite flies 21.0 kg airborne; AWETrim 22.0 kg since it added the 2.58 kg bridle to the wing (1.62 kg in V3Kite), 19.4 kg before. Co-Authored-By: Claude Opus 5.5 --- docs/src/examples_identification.md | 7 ++ examples/menu2.jl | 1 + examples/validate_masses.jl | 119 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 examples/validate_masses.jl diff --git a/docs/src/examples_identification.md b/docs/src/examples_identification.md index a932e949..dfd6c56c 100644 --- a/docs/src/examples_identification.md +++ b/docs/src/examples_identification.md @@ -179,3 +179,10 @@ 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/menu2.jl b/examples/menu2.jl index 39db1d41..6956f4a6 100644 --- a/examples/menu2.jl +++ b/examples/menu2.jl @@ -43,6 +43,7 @@ const IDENTIFICATION_SCRIPTS = [ "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/validate_masses.jl b/examples/validate_masses.jl new file mode 100644 index 00000000..3f9ca7a2 --- /dev/null +++ b/examples/validate_masses.jl @@ -0,0 +1,119 @@ +# 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 server flies by default (`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 +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 = normpath(joinpath(@__DIR__, "..", "..", "AWETrim")), + system_config = nothing)) + +"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 From edc039af4225924243f7776ac708fd52742a1561 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 20:49:04 +0100 Subject: [PATCH 16/39] AWETrim flies V3Kite's bridle mass, 1.62 kg; 26x11 startup retry AWETrim aa25dfc added its 2.58 kg bridle to the point-mass wing: 22.0 kg airborne against V3Kite's 21.0 kg (validate_masses.jl). Every /init now sends system_config_path = awetrim_system_config(), AWETrim's LEI-V3 system file with the bridle mass set to AWETRIM_BRIDLE_MASS = 1.62 kg, written to output/awetrim_system/ with the ROMs next to it; its content is part of the request key. At 12.62 kg Maasvlakte 5 m/s could not trim along any startup guess; a 26 x 11 guess converges, so it is the last size retry. 5 m/s flies: all 10 criteria, 6.1 kW, power ratio 0.99. Co-Authored-By: Claude Opus 5.5 --- data/traj_opt.yaml | 5 +-- docs/fig8_tuning_log.md | 30 +++++++++++++++++ examples/simple_opt_fig8.jl | 5 +-- examples/validate_masses.jl | 9 ++--- src/awetrim_client.jl | 66 ++++++++++++++++++++++++++++++++++--- src/reelout_loop.jl | 6 ++-- src/startup_path.jl | 3 +- 7 files changed, 108 insertions(+), 16 deletions(-) diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index 2a745ac0..96108972 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -20,8 +20,9 @@ traj_opt: 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 [deg] - startup_retry_guess_sizes: [[30.0, 13.0], [34.0, 15.0]] + # 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]] seed: # depower seed of the solve, ramped with the wind input_depower: 1.5 # depower SEED in metres of tape (server bounds 1.1-2.3), not depower_setpoint [m] diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 30038a26..4a396b81 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6522,3 +6522,33 @@ No single size covers all, so the guess is 32 x 14 and `startup_retry_guess_size (`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. diff --git a/examples/simple_opt_fig8.jl b/examples/simple_opt_fig8.jl index 85eff9ef..79f9d9c9 100644 --- a/examples/simple_opt_fig8.jl +++ b/examples/simple_opt_fig8.jl @@ -96,7 +96,7 @@ 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 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 +227,8 @@ 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()); 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/validate_masses.jl b/examples/validate_masses.jl index 3f9ca7a2..93eb4050 100644 --- a/examples/validate_masses.jl +++ b/examples/validate_masses.jl @@ -12,7 +12,8 @@ The canopy is the `extra_mass` spread over the wing points, the KCU that of poin 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 server flies by default (`system_config` overrides it), +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. @@ -26,7 +27,8 @@ if Base.active_project() != joinpath(@__DIR__, "Project.toml") Pkg.activate(joinpath(@__DIR__)) end -using RobustKiteControllers: run_example, script_inputs, project_file, read_gui_field +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 @@ -36,8 +38,7 @@ using Printf (; project, awetrim_dir, system_config) = script_inputs(@__FILE__, (; project = something(read_gui_field("project"), "system_reelout_maasvlakte.yaml"), - awetrim_dir = normpath(joinpath(@__DIR__, "..", "..", "AWETrim")), - system_config = nothing)) + 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 = """ diff --git a/src/awetrim_client.jl b/src/awetrim_client.jl index 346efdc9..27568db9 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -60,6 +60,55 @@ The AWETrim ROM every request flies against, as `rom_config_path` of `/init` and 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) @@ -128,6 +177,7 @@ Base.@kwdef struct InitParams 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 end Base.@kwdef struct StepParams @@ -257,15 +307,20 @@ 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))) + isnothing(p.system_config_path) || + (fields = (fields..., stable_hash(read(p.system_config_path, String)))) + return "v4-" * stable_hash(fields) end """ @@ -1490,7 +1545,8 @@ 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()) 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/reelout_loop.jl b/src/reelout_loop.jl index 224cd89b..7a1941c6 100644 --- a/src/reelout_loop.jl +++ b/src/reelout_loop.jl @@ -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, + system_config_path = awetrim_system_config()) # 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 +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, + system_config_path = awetrim_system_config()) 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/startup_path.jl b/src/startup_path.jl index ba8adbd7..63e5cc00 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -24,7 +24,8 @@ function startup_params(setup, el_center, size = nothing) 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()) end """ From 035908e9460dc0e06ac63280cc6375b67c9d7b3e Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 20:54:30 +0100 Subject: [PATCH 17/39] Update Maasvlakte overview: 5 m/s with the 1.62 kg bridle Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 71730830..6bd11a4d 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -2,7 +2,7 @@ | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | 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-28 | 14:09:49 | 4.0 | 0.92 | 147.7 | 5.35 | 3 | 2 | 2989 | 1644 | 5132 | 1176 | 1725 | 2595 | 1.39 | 1.69 | 2.03 | 0.23 | 0.53 | all 10 passed | -| 2026-10-28 | 14:13:00 | 5.0 | 0.97 | 175.9 | 5.98 | 4 | 3 | 6325 | 3372 | 9106 | 1803 | 2888 | 4010 | 1.78 | 2.17 | 2.46 | 0.23 | 0.6 | 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-30 | 17:27:57 | 6.0 | 0.99 | 184.3 | 5.74 | 3 | 3 | 11092 | 5630 | 14764 | 2430 | 4198 | 5185 | 2.04 | 2.62 | 2.91 | 0.23 | 0.68 | all 10 passed | | 2026-10-30 | 18:19:13 | 7.0 | 0.97 | 39.1 | 5.7 | 3 | 3 | 17858 | 10218 | 22691 | 3858 | 5689 | 6807 | 2.36 | 3.12 | 3.41 | 0.23 | 0.77 | all 10 passed | | 2026-10-30 | 17:29:06 | 8.0 | 0.94 | 64.2 | 7.22 | 2 | 2 | 19626 | 10197 | 24487 | 3841 | 6024 | 7029 | 2.37 | 3.23 | 3.5 | 0.242 | 0.74 | all 10 passed | From ddeeca49ef42b48d2f7a162c3d834b5b893d6d29 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 22:02:32 +0100 Subject: [PATCH 18/39] 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 the 4 m/s startup request has no solution under the pattern box at kv 0.0408; recorded and replayed in AWETrim it converges at kv 0.030 (and 0.025), not with any guess, seed, turn-radius or tether-length change. 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. build_winch sets wc.kv from it, which the winch flies and the requests send. Flown at 4 m/s: all 10 criteria, 3.1 kW, power ratio 0.99. OPT_REQUEST_RECORD (off by default) appends every POST body to a file for exact replays. Fixes the startup_retry_guess_sizes test edc039a broke. Co-Authored-By: Claude Opus 5.5 --- data/wc_settings.yaml | 8 ++++---- data/winch_table.yaml | 24 +++++++++++++++++++++--- docs/fig8_tuning_log.md | 15 +++++++++++++++ docs/src/api/winch.md | 1 + src/RobustKiteControllers.jl | 2 +- src/awetrim_client.jl | 10 ++++++++++ src/winch_setup.jl | 1 + src/winch_table.jl | 12 ++++++++++++ test/test_fig8_controller.jl | 12 ++++++++---- test/test_settings_files.jl | 4 ++-- 10 files changed, 75 insertions(+), 14 deletions(-) diff --git a/data/wc_settings.yaml b/data/wc_settings.yaml index cd6e8b96..ce27eefd 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 diff --git a/data/winch_table.yaml b/data/winch_table.yaml index d322d10e..5daa752a 100644 --- a/data/winch_table.yaml +++ b/data/winch_table.yaml @@ -1,11 +1,18 @@ # 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). 0.030 at 3 and 4 m/s, the measured value +# closest to the old one; 3 m/s is untested. 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 +58,34 @@ entries: - v_wind: 3.0 f_low: 350.0 force_limit: "soft" + kv: 0.030 - 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 4a396b81..561fad45 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6552,3 +6552,18 @@ Flown at 1.62 kg, Maasvlakte 5 m/s still failed every startup retry: the march b 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. diff --git a/docs/src/api/winch.md b/docs/src/api/winch.md index dbc38acb..a08b0b82 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/src/RobustKiteControllers.jl b/src/RobustKiteControllers.jl index b27283a4..4062384f 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 27568db9..e7d46c7a 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -541,6 +541,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 @@ -558,6 +565,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 diff --git a/src/winch_setup.jl b/src/winch_setup.jl index a7e79091..5a9c27bc 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 dd0ec1bd..2ccbc620 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/test_fig8_controller.jl b/test/test_fig8_controller.jl index 9e9f146c..630a74ba 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -805,6 +805,10 @@ 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(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 +861,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. @@ -1070,10 +1073,11 @@ end # 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" # 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). + # 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]] + @test tos.guess.startup_retry_guess_sizes == [[30.0, 13.0], [34.0, 15.0], [26.0, 11.0]] # 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 diff --git a/test/test_settings_files.jl b/test/test_settings_files.jl index 3e131670..3a4a3a7c 100644 --- a/test/test_settings_files.jl +++ b/test/test_settings_files.jl @@ -114,9 +114,9 @@ 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. @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. From b148356ac362882f1276eed58dfda3a22c26856f Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 22:03:50 +0100 Subject: [PATCH 19/39] Update Maasvlakte overview: 4 m/s with kv 0.030 Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 6bd11a4d..058810cc 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -1,7 +1,7 @@ | 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-28 | 14:09:49 | 4.0 | 0.92 | 147.7 | 5.35 | 3 | 2 | 2989 | 1644 | 5132 | 1176 | 1725 | 2595 | 1.39 | 1.69 | 2.03 | 0.23 | 0.53 | 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-30 | 17:27:57 | 6.0 | 0.99 | 184.3 | 5.74 | 3 | 3 | 11092 | 5630 | 14764 | 2430 | 4198 | 5185 | 2.04 | 2.62 | 2.91 | 0.23 | 0.68 | all 10 passed | | 2026-10-30 | 18:19:13 | 7.0 | 0.97 | 39.1 | 5.7 | 3 | 3 | 17858 | 10218 | 22691 | 3858 | 5689 | 6807 | 2.36 | 3.12 | 3.41 | 0.23 | 0.77 | all 10 passed | From 0c20bba9195bdff84e07b75a661bcc50c3b023ca Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 22:31:06 +0100 Subject: [PATCH 20/39] kv 0.020 at 3-3.5 m/s: Maasvlakte 3.5 m/s flies with the 12.62 kg wing The recorded 3.5 m/s startup request converges at kv 0.019-0.022 and fails at 0.015-0.018 and 0.023-0.030. New 3.5 m/s row at 0.020, and 0.020 at 3 m/s (untested) so kv does not rise as the wind drops. Flown: all 10 criteria, 1.9 kW, power ratio 1.02. Co-Authored-By: Claude Opus 5.5 --- data/winch_table.yaml | 13 +++++++++---- docs/fig8_tuning_log.md | 8 ++++++++ test/test_fig8_controller.jl | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/data/winch_table.yaml b/data/winch_table.yaml index 5daa752a..1f823138 100644 --- a/data/winch_table.yaml +++ b/data/winch_table.yaml @@ -9,9 +9,10 @@ # 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). 0.030 at 3 and 4 m/s, the measured value -# closest to the old one; 3 m/s is untested. The 5 m/s row holds 0.0408 so -# that 5 m/s and up fly as before. +# (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 @@ -58,7 +59,11 @@ entries: - v_wind: 3.0 f_low: 350.0 force_limit: "soft" - kv: 0.030 + 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" diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 561fad45..a50392a9 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6567,3 +6567,11 @@ limit relaxed, or the full box restored after a relaxed solve all fail); and a s `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. diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 630a74ba..d1285761 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -805,6 +805,7 @@ 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 From 790da1d81c89cb611702037e79b62458b5d31b55 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 22:31:44 +0100 Subject: [PATCH 21/39] Update Maasvlakte overview: 3.5 m/s with kv 0.020 Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 058810cc..78c75958 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -1,6 +1,6 @@ | 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-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-30 | 17:27:57 | 6.0 | 0.99 | 184.3 | 5.74 | 3 | 3 | 11092 | 5630 | 14764 | 2430 | 4198 | 5185 | 2.04 | 2.62 | 2.91 | 0.23 | 0.68 | all 10 passed | From 1198de941e8f323ba30c3b974c696add2d32e678 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Fri, 30 Oct 2026 22:36:10 +0100 Subject: [PATCH 22/39] Update Maasvlakte overview: 8.5 m/s with the 1.62 kg bridle Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 78c75958..86af5519 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -7,7 +7,7 @@ | 2026-10-30 | 18:19:13 | 7.0 | 0.97 | 39.1 | 5.7 | 3 | 3 | 17858 | 10218 | 22691 | 3858 | 5689 | 6807 | 2.36 | 3.12 | 3.41 | 0.23 | 0.77 | all 10 passed | | 2026-10-30 | 17:29:06 | 8.0 | 0.94 | 64.2 | 7.22 | 2 | 2 | 19626 | 10197 | 24487 | 3841 | 6024 | 7029 | 2.37 | 3.23 | 3.5 | 0.242 | 0.74 | all 10 passed | | 2026-10-30 | 18:20:24 | 8.25 | 0.92 | 56.9 | 7.07 | 2 | 2 | 19916 | 8885 | 25264 | 3505 | 6080 | 7287 | 2.29 | 3.24 | 3.5 | 0.258 | 0.84 | 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-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-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 | From 02bc86a9cf83ab8af630c77411b1e3c606fae5db Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 00:31:27 +0100 Subject: [PATCH 23/39] first_lap_force_frac 0.93 -> 0.96: Maasvlakte 9 m/s flies with the 12.62 kg wing The recorded 9 m/s startup request has no solution at the startup f_max of 6696 N with the 1.62 kg bridle; it converges from 6900 N up with the full pattern box. 0.96 (6912 N) is the smallest step that does. Flown: all 10 criteria (max force included), 17.8 kW, power ratio 0.89. Co-Authored-By: Claude Opus 5.5 --- data/fc_settings_reelout.yaml | 3 ++- docs/fig8_tuning_log.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index 44bde6e7..dea9fc8c 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] diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index a50392a9..c9a62ae1 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6575,3 +6575,17 @@ converging variant (seed 1.35 m, +2° and the relaxed box fail). New 3.5 m/s row 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. From 83d3718759bcc2bde2d7aea306aa53bb0481afb3 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 00:33:08 +0100 Subject: [PATCH 24/39] Update Maasvlakte overview: 9 m/s with first_lap_force_frac 0.96 Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 86af5519..165f4a6c 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -8,6 +8,6 @@ | 2026-10-30 | 17:29:06 | 8.0 | 0.94 | 64.2 | 7.22 | 2 | 2 | 19626 | 10197 | 24487 | 3841 | 6024 | 7029 | 2.37 | 3.23 | 3.5 | 0.242 | 0.74 | all 10 passed | | 2026-10-30 | 18:20:24 | 8.25 | 0.92 | 56.9 | 7.07 | 2 | 2 | 19916 | 8885 | 25264 | 3505 | 6080 | 7287 | 2.29 | 3.24 | 3.5 | 0.258 | 0.84 | 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-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-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-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 | From 4254fc8b71a71e9f5c19ae4b58a97b812cf76834 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 01:04:13 +0100 Subject: [PATCH 25/39] High-wind shape: no symmetry, no climb limit from 12.5 m/s aloft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the 1.62 kg bridle the recorded Maasvlakte 10 m/s startup request converges only with symmetric off AND no climb limit (or without the box or the bridle mass); force cap, kv, seed and guess do not help. New pattern_shape_wind_ref (12.5 m/s at 100 m) with pattern_symmetric_shape_high and pattern_climb_angle_max_shape_high, read by symmetric_at / climb_angle_max_at. Flown at 10 m/s: all 10 criteria, 18.1 kW, power ratio 0.85; paths centred within 2.4°. Co-Authored-By: Claude Opus 5.5 --- data/traj_opt.yaml | 5 +++++ docs/fig8_tuning_log.md | 13 +++++++++++++ src/pattern_limits.jl | 36 +++++++++++++++++++++++++++++++++--- src/traj_opt_settings.jl | 13 +++++++++++++ test/test_pattern_limits.jl | 15 +++++++++++++++ 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index 96108972..d6ae26d0 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -66,6 +66,11 @@ 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 [°] 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/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index c9a62ae1..c46a094f 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6589,3 +6589,16 @@ it was lowered to 0.93 on 2026-09-24 because 9 m/s then crossed the 8400 N ratin 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. diff --git a/src/pattern_limits.jl b/src/pattern_limits.jl index 9eb15542..7dd62e82 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/traj_opt_settings.jl b/src/traj_opt_settings.jl index c5f18aea..0ffca8c1 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -116,6 +116,14 @@ $(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 end """ @@ -470,6 +478,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/test/test_pattern_limits.jl b/test/test_pattern_limits.jl index 37afca68..402e2716 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) From da5eff1ee3f88b5252081084bf966aaeae612e36 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 01:05:31 +0100 Subject: [PATCH 26/39] Update Maasvlakte overview: 10 m/s with the high-wind shape Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 165f4a6c..592cb0b2 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -9,5 +9,5 @@ | 2026-10-30 | 18:20:24 | 8.25 | 0.92 | 56.9 | 7.07 | 2 | 2 | 19916 | 8885 | 25264 | 3505 | 6080 | 7287 | 2.29 | 3.24 | 3.5 | 0.258 | 0.84 | 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-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-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-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 | From 01f736eacf7fa080e5483d8f876c6de49e41e350 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 04:24:39 +0100 Subject: [PATCH 27/39] High-wind start length: Maasvlakte 11 m/s starts at 180 m With the 12.62 kg wing the 11 m/s startup has no solution at 150 m; 160 m converges but fails the curvature gate (margin 0.79). FC_LowWind gets high_wind_speed (14.0 m/s at the reference height) and high_wind_l_tether (180 m); 10 m/s keeps 150 m. Co-Authored-By: Claude Opus 5.5 --- data/fc_settings_reelout.yaml | 5 +++++ docs/fig8_tuning_log.md | 13 +++++++++++++ src/fc_settings.jl | 5 +++++ src/low_wind_schedule.jl | 14 ++++++++++++-- test/test_fig8_controller.jl | 1 + test/test_settings_files.jl | 8 ++++++++ 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index dea9fc8c..9faa23b7 100644 --- a/data/fc_settings_reelout.yaml +++ b/data/fc_settings_reelout.yaml @@ -124,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: 180.0 # Tether length reel-out starts at from high_wind_speed on; 160 converged but + # failed the curvature gate at margin 0.79 (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) [-] diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index c46a094f..94d0255e 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6602,3 +6602,16 @@ false and `pattern_climb_angle_max_shape_high` 0 (off). Flown at 10 m/s: all 10 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. diff --git a/src/fc_settings.jl b/src/fc_settings.jl index 31d205e4..21df1dea 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 """ diff --git a/src/low_wind_schedule.jl b/src/low_wind_schedule.jl index b2a18843..ed2b62c8 100644 --- a/src/low_wind_schedule.jl +++ b/src/low_wind_schedule.jl @@ -43,7 +43,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), 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 +64,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/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index d1285761..096b2954 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1166,6 +1166,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 == 180.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 diff --git a/test/test_settings_files.jl b/test/test_settings_files.jl index 3a4a3a7c..e16cac33 100644 --- a/test/test_settings_files.jl +++ b/test/test_settings_files.jl @@ -115,6 +115,14 @@ const GUI_DEFAULT = read(joinpath(rkc_data_path(), "gui.yaml.default"), String) (; 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. + # The start length: 150 m at 10 m/s (12.9 m/s aloft), 180 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, 180.0)) + s = KiteUtils.Settings(project) + RobustKiteControllers.apply_windspeed_override!(s, v) + apply_low_wind_schedule!(fro, nothing, s) + @test s.l_tether == l + 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) From 2f5af60df9097adcc7c5455a05def62699dfd293 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 04:25:22 +0100 Subject: [PATCH 28/39] Update Maasvlakte overview: 11 m/s starting at 180 m Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_maasvlakte.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 592cb0b2..618dc9bd 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -10,4 +10,4 @@ | 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-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-31 | 01:41:42 | 11.0 | 0.82 | 186.7 | 4.23 | 2 | 2 | 17945 | 9205 | 22804 | 3468 | 5703 | 6720 | 2.2 | 3.12 | 3.43 | 0.322 | 0.78 | all 10 passed | From 4400e1978b1b8bb0547825c905a6ec8d61370525 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 05:14:33 +0100 Subject: [PATCH 29/39] Update overviews: Maasvlakte 6-8.25 m/s and Cabauw 5-7 m/s reflown Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_cabauw.md | 12 ++++++------ notebooks/overview_maasvlakte.md | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index 26aa4e52..87e21812 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -2,12 +2,12 @@ | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | 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-30 | 18:40:08 | 4.0 | 1.01 | 243.5 | 6.68 | 3 | 2 | 10667 | 5462 | 15387 | 2440 | 4104 | 5302 | 1.87 | 2.58 | 2.96 | 0.23 | 0.57 | all 10 passed | -| 2026-10-30 | 18:41:47 | 5.0 | 0.99 | 91.9 | 7.09 | 2 | 2 | 21220 | 14357 | 25722 | 4937 | 6369 | 7384 | 2.7 | 3.32 | 3.5 | 0.23 | 0.69 | all 10 passed | -| 2026-10-30 | 18:42:42 | 5.5 | 0.98 | 49.1 | 7.22 | 2 | 2 | 22313 | 15527 | 25383 | 5137 | 6561 | 7255 | 2.81 | 3.39 | 3.5 | 0.25 | 0.73 | all 10 passed | -| 2026-10-30 | 18:43:41 | 5.75 | 0.96 | 52.2 | 6.85 | 2 | 2 | 21097 | 12822 | 24660 | 4549 | 6316 | 7069 | 2.54 | 3.32 | 3.5 | 0.262 | 0.74 | 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-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-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 | diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 618dc9bd..81430df1 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -3,10 +3,10 @@ | 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-30 | 17:27:57 | 6.0 | 0.99 | 184.3 | 5.74 | 3 | 3 | 11092 | 5630 | 14764 | 2430 | 4198 | 5185 | 2.04 | 2.62 | 2.91 | 0.23 | 0.68 | all 10 passed | -| 2026-10-30 | 18:19:13 | 7.0 | 0.97 | 39.1 | 5.7 | 3 | 3 | 17858 | 10218 | 22691 | 3858 | 5689 | 6807 | 2.36 | 3.12 | 3.41 | 0.23 | 0.77 | all 10 passed | -| 2026-10-30 | 17:29:06 | 8.0 | 0.94 | 64.2 | 7.22 | 2 | 2 | 19626 | 10197 | 24487 | 3841 | 6024 | 7029 | 2.37 | 3.23 | 3.5 | 0.242 | 0.74 | all 10 passed | -| 2026-10-30 | 18:20:24 | 8.25 | 0.92 | 56.9 | 7.07 | 2 | 2 | 19916 | 8885 | 25264 | 3505 | 6080 | 7287 | 2.29 | 3.24 | 3.5 | 0.258 | 0.84 | 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 | From 2a4a407093460ab1fbd5e1b273020b121f82f771 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 05:52:55 +0100 Subject: [PATCH 30/39] reel_in_beta 20 -> 22: Cabauw 3 m/s flies with kv 0.020 kv 0.020 at f_low 350 N needs reel_in_beta >= 21.4. Logs the Cabauw rebuild and the startup failures at 4, 8, 9 and 10 m/s. Co-Authored-By: Claude Opus 5.5 --- data/wc_settings.yaml | 2 +- docs/fig8_tuning_log.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/data/wc_settings.yaml b/data/wc_settings.yaml index ce27eefd..890d8dfd 100644 --- a/data/wc_settings.yaml +++ b/data/wc_settings.yaml @@ -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/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 94d0255e..81b5ca9a 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6615,3 +6615,22 @@ wing (14.8 kW) and at a longer start: 160 m (17.0 kW), 165, 170 and 180 m (18.4 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`. From 296b5703a7741d563ead9b9665ac1b90c1cd606a Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 05:53:27 +0100 Subject: [PATCH 31/39] Update Cabauw overview: 3 m/s with reel_in_beta 22 Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_cabauw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index 87e21812..d2f6e432 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -1,6 +1,6 @@ | 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-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-30 | 18:40:08 | 4.0 | 1.01 | 243.5 | 6.68 | 3 | 2 | 10667 | 5462 | 15387 | 2440 | 4104 | 5302 | 1.87 | 2.58 | 2.96 | 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 | From 124312b22cead71024017569ed7a49e089f61773 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 06:12:42 +0100 Subject: [PATCH 32/39] Startup length retry; high-wind start at 170 m When no startup seed converges, setup_with_startup sets the run up again with the tether startup_retry_length_steps longer (traj_opt.yaml: [20.0]), through the new l_tether run input; solve_startup throws StartupNoPath for that. Cabauw 4 m/s retries at 170 m and flies. high_wind_l_tether 180 -> 170: Maasvlakte 11 m/s flies there. Cabauw 8 m/s still fails laps >= 2.5. Co-Authored-By: Claude Opus 5.5 --- data/fc_settings_reelout.yaml | 4 ++-- data/traj_opt.yaml | 3 +++ docs/fig8_tuning_log.md | 10 ++++++++++ docs/src/api/internals.md | 2 ++ examples/simple_opt_reelout.jl | 19 +++++++------------ src/awetrim_client.jl | 25 ++++++++++++++++++------- src/run_inputs.jl | 5 ++++- src/run_setup.jl | 4 +++- src/startup_path.jl | 32 ++++++++++++++++++++++++++++++++ src/traj_opt_settings.jl | 2 ++ test/test_awetrim_client.jl | 4 ++-- test/test_fig8_controller.jl | 3 ++- test/test_settings_files.jl | 4 ++-- test/test_startup_pipeline.jl | 28 +++++++++++++++++++--------- 14 files changed, 108 insertions(+), 37 deletions(-) diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index 9faa23b7..9e922951 100644 --- a/data/fc_settings_reelout.yaml +++ b/data/fc_settings_reelout.yaml @@ -127,8 +127,8 @@ fc_settings: # 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: 180.0 # Tether length reel-out starts at from high_wind_speed on; 160 converged but - # failed the curvature gate at margin 0.79 (2026-10-31) [m] + 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) [-] diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index d6ae26d0..e2e0af65 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -23,6 +23,9 @@ traj_opt: # 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 [m] + startup_retry_length_steps: [20.0] seed: # depower seed of the solve, ramped with the wind input_depower: 1.5 # depower SEED in metres of tape (server bounds 1.1-2.3), not depower_setpoint [m] diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 81b5ca9a..8247f304 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6634,3 +6634,13 @@ Still failing, from recorded requests replayed in-process: 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. diff --git a/docs/src/api/internals.md b/docs/src/api/internals.md index cca9e7f3..2acf43d5 100644 --- a/docs/src/api/internals.md +++ b/docs/src/api/internals.md @@ -32,6 +32,8 @@ 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/examples/simple_opt_reelout.jl b/examples/simple_opt_reelout.jl index 1a2f0461..b8a87094 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/src/awetrim_client.jl b/src/awetrim_client.jl index e7d46c7a..561a53eb 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -1407,6 +1407,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) @@ -1422,9 +1433,9 @@ elevation with guess `size = (guess_a, guess_b)` (`nothing` = the shipped size), 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. -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 within reach 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) @@ -1484,11 +1495,11 @@ 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 \ + throw(StartupNoPath(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 \ 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: @@ -1507,7 +1518,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 diff --git a/src/run_inputs.jl b/src/run_inputs.jl index 2cf28cc1..04a18213 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 4abbf060..9a9f4903 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 63e5cc00..59a53ae1 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -79,6 +79,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/traj_opt_settings.jl b/src/traj_opt_settings.jl index 0ffca8c1..b3951dd2 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -51,6 +51,8 @@ $(TYPEDFIELDS) 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 """ diff --git a/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index da4d0939..22fa7d18 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -250,7 +250,7 @@ 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. @@ -267,7 +267,7 @@ 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) diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 096b2954..a09ae187 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1079,6 +1079,7 @@ end @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 == [20.0] # 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 @@ -1166,7 +1167,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 == 180.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 diff --git a/test/test_settings_files.jl b/test/test_settings_files.jl index e16cac33..7d7ef6a9 100644 --- a/test/test_settings_files.jl +++ b/test/test_settings_files.jl @@ -115,9 +115,9 @@ const GUI_DEFAULT = read(joinpath(rkc_data_path(), "gui.yaml.default"), String) (; 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. - # The start length: 150 m at 10 m/s (12.9 m/s aloft), 180 m from 14 m/s aloft (11 m/s). + # 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, 180.0)) + 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) diff --git a/test/test_startup_pipeline.jl b/test/test_startup_pipeline.jl index 6ae7708d..ed9e6034 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) @@ -125,6 +122,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) From 8e9863a1ad5d6d8d8c83537f25e50025aa5c0a80 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 06:18:59 +0100 Subject: [PATCH 33/39] Update overviews: Cabauw 4 m/s and Maasvlakte 11 m/s at 170 m Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_cabauw.md | 2 +- notebooks/overview_maasvlakte.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index d2f6e432..e3fdbde3 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -1,7 +1,7 @@ | 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-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-30 | 18:40:08 | 4.0 | 1.01 | 243.5 | 6.68 | 3 | 2 | 10667 | 5462 | 15387 | 2440 | 4104 | 5302 | 1.87 | 2.58 | 2.96 | 0.23 | 0.57 | 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 | diff --git a/notebooks/overview_maasvlakte.md b/notebooks/overview_maasvlakte.md index 81430df1..f8b426b1 100644 --- a/notebooks/overview_maasvlakte.md +++ b/notebooks/overview_maasvlakte.md @@ -10,4 +10,4 @@ | 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 | 01:41:42 | 11.0 | 0.82 | 186.7 | 4.23 | 2 | 2 | 17945 | 9205 | 22804 | 3468 | 5703 | 6720 | 2.2 | 3.12 | 3.43 | 0.322 | 0.78 | 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 | From 2fcd0c8034908c23702e1828a677a5fdd71a5eda Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 08:19:12 +0100 Subject: [PATCH 34/39] =?UTF-8?q?Angle-of-attack=20floor=20-7=C2=B0,=20the?= =?UTF-8?q?=20ROM's=20validity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWETrim's default floor of 0° leaves Cabauw 9 m/s without a startup solution: the depowered kite trims at -0.5 to +0.4°. box.angle_of_attack_min (traj_opt.yaml: -7.0) is sent with every /init as angle_of_attack_limits; unset, it is neither sent nor in the cache key. Cabauw 8 m/s flies with it. Co-Authored-By: Claude Opus 5.5 --- data/traj_opt.yaml | 2 ++ docs/fig8_tuning_log.md | 12 ++++++++++++ docs/src/api/internals.md | 2 ++ examples/simple_opt_fig8.jl | 6 ++++-- src/awetrim_client.jl | 33 ++++++++++++++++++++++++++++++--- src/reelout_loop.jl | 6 ++++-- src/startup_path.jl | 3 ++- src/traj_opt_settings.jl | 2 ++ test/test_awetrim_client.jl | 4 ++++ test/test_fig8_controller.jl | 3 +++ test/test_startup_pipeline.jl | 1 + 11 files changed, 66 insertions(+), 8 deletions(-) diff --git a/data/traj_opt.yaml b/data/traj_opt.yaml index e2e0af65..6d0ab84b 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -74,6 +74,8 @@ traj_opt: # 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/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 8247f304..25a21c3b 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6644,3 +6644,15 @@ input). Cabauw 4 m/s fails every seed at 150 m and retries at 170 m: all 10 crit 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. diff --git a/docs/src/api/internals.md b/docs/src/api/internals.md index 2acf43d5..4c1d6d34 100644 --- a/docs/src/api/internals.md +++ b/docs/src/api/internals.md @@ -22,6 +22,8 @@ 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! diff --git a/examples/simple_opt_fig8.jl b/examples/simple_opt_fig8.jl index 79f9d9c9..330ffcd4 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, awetrim_system_config +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, @@ -228,7 +229,8 @@ opt_reply = opt_init(InitParams(; name = OPT_NAME, length = l0, reg_weight = REG_WEIGHT, min_turn_radius = opt_r_min, pattern_limits = opt_box, - system_config_path = awetrim_system_config()); + 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/src/awetrim_client.jl b/src/awetrim_client.jl index 561a53eb..ca2936c4 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -161,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 @@ -178,6 +198,7 @@ Base.@kwdef struct InitParams 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 @@ -260,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 @@ -317,9 +341,11 @@ upgrade. `"v4-"` retires the `hash`-based `"v3-"` keys. """ function opt_request_key(p::InitParams) fields = Tuple(_key_fields(getfield(p, f)) for f in fieldnames(InitParams) - if !(f in (:name, :system_config_path))) + 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 @@ -339,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 @@ -1567,7 +1593,8 @@ function free_speed_reference(tos, wc, inflow, guess_az, guess_el, lengths; input_depower = depower_seed(tos, inflow.wind_speed), reg_weight = REG_WEIGHT, min_turn_radius, pattern_limits, - system_config_path = awetrim_system_config()) + 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/reelout_loop.jl b/src/reelout_loop.jl index 7a1941c6..46cadadb 100644 --- a/src/reelout_loop.jl +++ b/src/reelout_loop.jl @@ -318,7 +318,8 @@ function request_reopt!(st::RunState, setup, t, phase, l_now) depower = depower_spec(tos), min_turn_radius = st.opt_r_min, pattern_limits = st.opt_box_now, - system_config_path = awetrim_system_config()) + 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) @@ -598,7 +599,8 @@ function solve_cold!(st::RunState, setup, l_now, el_seed, box) depower = depower_spec(tos), min_turn_radius = st.opt_r_min, pattern_limits = box, - system_config_path = awetrim_system_config()) + 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/startup_path.jl b/src/startup_path.jl index 59a53ae1..3f167545 100644 --- a/src/startup_path.jl +++ b/src/startup_path.jl @@ -25,7 +25,8 @@ function startup_params(setup, el_center, size = nothing) 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, - system_config_path = awetrim_system_config()) + system_config_path = awetrim_system_config(), + angle_of_attack_limits = aoa_limits(tos)) end """ diff --git a/src/traj_opt_settings.jl b/src/traj_opt_settings.jl index b3951dd2..4c1a17b5 100644 --- a/src/traj_opt_settings.jl +++ b/src/traj_opt_settings.jl @@ -126,6 +126,8 @@ $(TYPEDFIELDS) 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 """ diff --git a/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index 22fa7d18..cebd361e 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -60,6 +60,8 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n 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)), @@ -68,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. diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index a09ae187..0cd39986 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1080,6 +1080,9 @@ end @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 == [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 diff --git a/test/test_startup_pipeline.jl b/test/test_startup_pipeline.jl index ed9e6034..ef6ec0b7 100644 --- a/test/test_startup_pipeline.jl +++ b/test/test_startup_pipeline.jl @@ -110,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 From 94b4fcb6a83d05a7bcd07edc5edad3f4af5ac615 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sat, 31 Oct 2026 08:20:19 +0100 Subject: [PATCH 35/39] Update Cabauw overview: 8 m/s with the angle-of-attack floor Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_cabauw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index e3fdbde3..a1bfbdcd 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -8,6 +8,6 @@ | 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-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-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-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 | From 68a5306cdd7d7d8b685b9cc293cee185d7d652b8 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sun, 1 Nov 2026 03:51:37 +0100 Subject: [PATCH 36/39] =?UTF-8?q?Cabauw=209=20and=2010=20m/s:=20high-wind?= =?UTF-8?q?=20box=2045=C2=B0=20x=2020=C2=B0,=202.0=20laps=20from=2016.5=20?= =?UTF-8?q?m/s=20aloft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 16.5 m/s at 100 m (only Cabauw 9 and 10 m/s) the box's azimuth cap is 45° (was 36) and its elevation half-span cap 20° (was 15); neither wind has a startup solution otherwise. startup_retry_length_steps [10, 20] gives 10 m/s its 180 m start. Their 30-32 s laps hold 2.0 laps, so the laps criterion is run.min_laps_high (2.0) from min_laps_high_wind (16.5 m/s). Both fly all 10 criteria: 15.0 and 15.2 kW. Co-Authored-By: Claude Opus 5.5 --- data/fc_settings_reelout.yaml | 4 ++++ data/traj_opt.yaml | 14 +++++++++----- docs/fig8_tuning_log.md | 15 +++++++++++++++ docs/src/api/internals.md | 1 + examples/reelout_results.jl | 1 + src/fc_settings.jl | 6 ++++++ src/low_wind_schedule.jl | 12 ++++++++++++ test/test_fig8_controller.jl | 10 ++++++---- test/test_settings_files.jl | 8 ++++++++ 9 files changed, 62 insertions(+), 9 deletions(-) diff --git a/data/fc_settings_reelout.yaml b/data/fc_settings_reelout.yaml index 9e922951..4d831420 100644 --- a/data/fc_settings_reelout.yaml +++ b/data/fc_settings_reelout.yaml @@ -139,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 6d0ab84b..f7f4fc14 100644 --- a/data/traj_opt.yaml +++ b/data/traj_opt.yaml @@ -24,8 +24,9 @@ traj_opt: # 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 [m] - startup_retry_length_steps: [20.0] + # 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.5 # depower SEED in metres of tape (server bounds 1.1-2.3), not depower_setpoint [m] @@ -50,7 +51,9 @@ traj_opt: box: # pattern box the optimizer solves UNDER; 0.0 keeps the server default (|az| <= 45.8, 0.6 <= el <= 51.6) 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: 36.0 # cap at/above pattern_elevation_amplitude_max_wind_ref: under the 45° climb + 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): @@ -58,8 +61,9 @@ traj_opt: # at 34 ±22° x 16°, RMS d 1.44°, 22.7 instead of 20.4 kW [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: 15.0 # cap at/above the ref wind (a step, not a ramp); 13 until 2026-10-28 [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); diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 25a21c3b..53de5346 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6656,3 +6656,18 @@ with every `/init` as `angle_of_attack_limits`. Cabauw 10 m/s converges with it 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. diff --git a/docs/src/api/internals.md b/docs/src/api/internals.md index 4c1d6d34..86b6588d 100644 --- a/docs/src/api/internals.md +++ b/docs/src/api/internals.md @@ -30,6 +30,7 @@ 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 diff --git a/examples/reelout_results.jl b/examples/reelout_results.jl index 15d3bb6c..59d08b52 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)) diff --git a/src/fc_settings.jl b/src/fc_settings.jl index 21df1dea..9d1f4db2 100644 --- a/src/fc_settings.jl +++ b/src/fc_settings.jl @@ -318,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 ed2b62c8..5e9c92b9 100644 --- a/src/low_wind_schedule.jl +++ b/src/low_wind_schedule.jl @@ -39,6 +39,18 @@ 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 diff --git a/test/test_fig8_controller.jl b/test/test_fig8_controller.jl index 0cd39986..4871f1c2 100644 --- a/test/test_fig8_controller.jl +++ b/test/test_fig8_controller.jl @@ -1079,7 +1079,7 @@ end @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 == [20.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))) @@ -1097,9 +1097,11 @@ end # 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). # 13° -> 15° (2026-10-28), not below the low-wind cap. - @test tos.box.pattern_elevation_amplitude_max_high == 15.0 + # 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 @@ -1107,7 +1109,7 @@ end # and tracked worse (2026-10-05, b68455e). # Both 36° for rom_config_aerostructural2 (2026-10-28). @test tos.box.pattern_azimuth_max == 36.0 - @test tos.box.pattern_azimuth_max_high == 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 diff --git a/test/test_settings_files.jl b/test/test_settings_files.jl index 7d7ef6a9..818ea0d7 100644 --- a/test/test_settings_files.jl +++ b/test/test_settings_files.jl @@ -123,6 +123,14 @@ const GUI_DEFAULT = read(joinpath(rkc_data_path(), "gui.yaml.default"), String) 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) From 22e0919fc531a8fd3cf7f6ef7ccdaac4dd981d0f Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sun, 1 Nov 2026 03:52:47 +0100 Subject: [PATCH 37/39] Update Cabauw overview: 9 and 10 m/s with the high-wind box Co-Authored-By: Claude Opus 5.5 --- notebooks/overview_cabauw.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/overview_cabauw.md b/notebooks/overview_cabauw.md index a1bfbdcd..7e313978 100644 --- a/notebooks/overview_cabauw.md +++ b/notebooks/overview_cabauw.md @@ -9,5 +9,5 @@ | 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-10-26 | 21:22:33 | 9.0 | 0.82 | 148.0 | 5.95 | 3 | 3 | 18982 | 14458 | 22246 | 4882 | 5911 | 6608 | 2.9 | 3.21 | 3.4 | 0.356 | 1.12 | all 10 passed | -| 2026-10-26 | 21:23:31 | 10.0 | 0.82 | 51.6 | 3.46 | 3 | 3 | 19046 | 14401 | 21806 | 4846 | 5926 | 6526 | 2.9 | 3.21 | 3.42 | 0.377 | 1.43 | all 10 passed | +| 2026-11-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 | From 67536a288237ed3020e6281cae3834509c99fd58 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sun, 1 Nov 2026 03:54:27 +0100 Subject: [PATCH 38/39] Dpn't skip --- examples/build_all_scenarios.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/build_all_scenarios.jl b/examples/build_all_scenarios.jl index 485fb934..3c27c3d4 100644 --- a/examples/build_all_scenarios.jl +++ b/examples/build_all_scenarios.jl @@ -46,7 +46,7 @@ const SITE_PROJECTS = [ ] "Wind speeds [m/s] not flown at a site, e.g. because their scenario is already up to date" const SKIP_WINDS = Dict( - "maasvlakte" => [6.0, 8.0], + "maasvlakte" => [], "cabauw" => Float64[], ) output_dir = normpath(joinpath(@__DIR__, "..", "output")) From 2b5f1b1ff40f91db1ee481afec9abb9c21a7fcf0 Mon Sep 17 00:00:00 2001 From: Uwe Fechner Date: Sun, 1 Nov 2026 12:14:47 +0100 Subject: [PATCH 39/39] A cached startup seed uses up its retry A rerun skipped the failed startup seeds as cached but, since a skip cost no retry, spent its budget on new seeds walked outward, ~4 min of 422s per rerun at Cabauw 10 m/s. Each listed seed is now tried once, sent or skipped, so a rerun moves on to the next length at once. startup_seed_offsets is removed. Co-Authored-By: Claude Opus 5.5 --- docs/fig8_tuning_log.md | 11 +++++++++ examples/reelout_results.jl | 6 ++--- src/awetrim_client.jl | 47 +++++++++++++++---------------------- src/startup_retry.jl | 20 ---------------- test/test_awetrim_client.jl | 18 ++++++++++---- test/test_startup_retry.jl | 13 +--------- 6 files changed, 47 insertions(+), 68 deletions(-) diff --git a/docs/fig8_tuning_log.md b/docs/fig8_tuning_log.md index 53de5346..5b191d39 100644 --- a/docs/fig8_tuning_log.md +++ b/docs/fig8_tuning_log.md @@ -6671,3 +6671,14 @@ Flown: both converge and track, but their 30-32 s laps from 170 and 180 m hold 2 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/examples/reelout_results.jl b/examples/reelout_results.jl index 59d08b52..3e59ebb2 100644 --- a/examples/reelout_results.jl +++ b/examples/reelout_results.jl @@ -305,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/src/awetrim_client.jl b/src/awetrim_client.jl index ca2936c4..89a0ccf2 100644 --- a/src/awetrim_client.jl +++ b/src/awetrim_client.jl @@ -1452,20 +1452,19 @@ Base.showerror(io::IO, e::StartupNoPath) = print(io, e.msg) The STARTUP solve, from `start_params` (the shipped guess) and, if the optimizer answers 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, then whole degrees walked -outward. `make_params(el_center, size)` builds the `/init` request seeded at that centre +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 a -[`StartupNoPath`](@ref) when every seed within reach is cached as bad, or when the optimizer +[`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 @@ -1473,15 +1472,11 @@ 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 sizes = [(s[1], s[2]) for s in tos.guess.startup_retry_guess_sizes] - offsets = startup_seed_offsets(tos.guess.startup_retry_el_offsets) - seeds = [[(0.0, nothing)]; [(0.0, s) for s in sizes]; [(o, nothing) for o in offsets[2:end]]] - budget = 1 + length(sizes) + length(tos.guess.startup_retry_el_offsets) - sent_offsets = Float64[] - sent_sizes = Tuple{Float64, Float64}[] - for (offset, size) in seeds - sent < budget || break + 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 && isnothing(size) ? start_params : make_params(el_center, size) cached = tos.server.opt_failure_cache ? opt_failed_before(params; file = failure_file) : nothing @@ -1495,18 +1490,14 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas @warn cached_msg continue end - sent += 1 - isnothing(size) ? push!(sent_offsets, offset) : push!(sent_sizes, size) - sent == 1 || isnothing(size) || + 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, sent - 1, budget - 1, size...) - sent == 1 || !isnothing(size) || + 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 @@ -1521,8 +1512,8 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas end end isnothing(opt_result) && isnothing(last_422) && - throw(StartupNoPath(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) && throw(StartupNoPath(""" @@ -1532,10 +1523,10 @@ function solve_startup(tos, make_params, solve, start_params, el_center_seed_bas * 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)")$(isempty(sent_sizes) ? "" : - ", and so did the guess sizes $(sent_sizes)° (startup_retry_guess_sizes)"), 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. diff --git a/src/startup_retry.jl b/src/startup_retry.jl index 56686684..5a4439b6 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/test/test_awetrim_client.jl b/test/test_awetrim_client.jl index cebd361e..b301f3ed 100644 --- a/test/test_awetrim_client.jl +++ b/test/test_awetrim_client.jl @@ -256,10 +256,20 @@ step_key(oc, sp) = chain_key(oc.state, (_key_fields(sp), _key_fields(nothing), n end @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[]) diff --git a/test/test_startup_retry.jl b/test/test_startup_retry.jl index d8d87293..02ad5fe9 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