Flight-path geometry
Reference paths are closed curves in azimuth and elevation [deg].
Externally optimized flight path
SimpleKiteControllers.TrajOptSettings — Type
TrajOptSettingsSettings of a run flown along an EXTERNALLY optimized path (examples/simple_opt_fig8.jl, examples/simple_opt_reelout.jl): where the AWETrim server is, the initial guess the solve starts from, the box it solves under, and the gates the path that comes back must pass. Loaded from data/traj_opt.yaml the same way FC_Settings is loaded from its own — a run is defined by a file, not by editing a script. The settings are split by what reads them; each part is its own struct and its own section of the YAML file.
A field is reached through its part, e.g. tos.box.pattern_azimuth_max. The field names are unique over all parts, so the constructors and apply_overrides! also take a field by its bare name: TrajOptSettings(; min_height = 40.0).
The conditions are NOT here: the wind comes from the system project's settings file and the winch law from its wc_settings, both read off the same files the plant is built from (inflow_from_settings, winch_from_wc in awetrim_client.jl). A path optimized for a wind the kite does not fly in is not the path for the run.
The initial guess is not a formality, which is why it has its own fields here rather than borrowing FC_Settings' f8_a/f8_b/el_center. Those size the lemniscate that simple_fig8.jl and simple_reelout.jl actually FLY; here the lemniscate is only a seed, and the two roles pull in different directions. Measured 2026-08-18 at 150 m and 6 m/s: the reel-out pattern (20°/11° at 18°) makes the solve fail to converge, while 30°/12°, or the same eight centred at 26°, converge — and to the same optimum, worth 6080 W, while the server's own parametric guess converges to a different one worth 1431 W. The problem is multi-modal, so the guess is a choice about the answer.
Fields
server::TrajOptServer: AWETrim server, request caches and the winch law sentguess::TrajOptGuess: The initial guess the startup solve starts fromseed::TrajOptSeed: The depower seed of the solvebox::TrajOptBox: The pattern box the optimizer solves undergates::TrajOptGates: The gates every returned path must passreopt::TrajOptReopt: Re-optimization while the tether growsreopt_gates::TrajOptReoptGates: The extra gates of a re-optimization reply
SimpleKiteControllers.TrajOptServer — Type
The AWETrim server, the request caches and the winch law sent, part server of TrajOptSettings, section server: of the YAML file.
Fields
base_url::String: Address of the AWETrim server Default: http://127.0.0.1:8000opt_failure_cache::Bool: Skip optimizer requests recorded as failed before (OPT_FAILURE_CACHE) Default: trueopt_success_cache::Bool: Replay previously applied optimizer results (OPT_CHAIN_CACHE) Default: trueopt_warm_start_awe_trim::Float64:use_awe_trimof a warm-up solve sent before the startup request;0.0= off Default: 0.0opt_awe_trim::Float64:use_awe_trimsent to AWETrim; negative followswc.use_awe_trimDefault: -1.0free_speed_reference_points::Int64: Lengths of the post-runfree_speedreference power solve;0= off Default: 0
SimpleKiteControllers.TrajOptGuess — Type
The initial guess the startup solve starts from, part guess of TrajOptSettings, section guess: of the YAML file.
Fields
guess_a::Float64: Width of the guess lemniscate; azimuth spans ±guess_a[deg] Default: 30.0guess_b::Float64: Guess height; elevation spansguess_bpeak to peak [deg] Default: 12.0guess_el_center::Float64: Centre elevation of the guess [deg] Default: 26.0guess_el_center_high::Float64: Guess centre elevation at and aboveguess_el_center_wind_ref[deg];0.0= off Default: 0.0guess_el_center_wind_ref::Float64: Wind speed at and above whichguess_el_center_highis used [m/s] Default: 0.0startup_retry_el_offsets::Vector{Float64}: Guess elevation offsets tried in order when the startup solve fails [deg] Default: Float64[]
SimpleKiteControllers.TrajOptSeed — Type
The depower seed of the solve, ramped with the wind, part seed of TrajOptSettings, section seed: of the YAML file.
Fields
input_depower::Float64: Power-tape length seedl_dpon AWETrim's scale [m] Default: 1.6input_depower_wind_ref::Float64: Wind speed at whichinput_depoweris the seed [m/s] Default: 7.0input_depower_per_wind::Float64: Tape length added to the seed per m/s aboveinput_depower_wind_ref[m/(m/s)] Default: 0.0input_depower_seed_max::Float64: Soft ceiling on the ramped depower seed [m];0.0= AWETrim's hard bound only Default: 0.0
SimpleKiteControllers.TrajOptBox — Type
The pattern box the optimizer solves UNDER, sent with every request so a reply cannot break it, part box of TrajOptSettings, section box: of the YAML file. Each bound is off at 0.0.
Fields
pattern_azimuth_max::Float64: Azimuth half-width limit of the optimized pattern [deg];0.0= optimizer's 45.8° Default: 0.0pattern_azimuth_max_high::Float64: Azimuth half-width limit at and above the high-wind step [deg];0.0= off Default: 0.0pattern_elevation_amplitude_max::Float64: Largest (RMS) elevation half-span of the optimized figure [deg];0.0= off Default: 0.0pattern_elevation_amplitude_max_high::Float64: Elevation half-span cap at and above the high-wind step [deg];0.0= off Default: 0.0pattern_elevation_amplitude_max_wind_ref::Float64: Wind speed aloft at and above which the high-wind caps apply [m/s] Default: 0.0pattern_elevation_amplitude_max_wind_height::Float64: Height the high-wind step's wind speed is measured at [m];0.0= ground wind Default: 0.0pattern_symmetric::Bool: Force a mirror-symmetric figure-eight Default: falsepattern_climb_angle_max::Float64: Steepest climb angle of the optimized path [deg];0.0= off Default: 0.0
SimpleKiteControllers.TrajOptGates — Type
The gates every returned path must pass before it is flown, at startup and after a re-optimization, part gates of TrajOptSettings, section gates: of the YAML file. min_feasibility_margin is also sent with the request, as a minimum turn radius.
Fields
min_feasibility_margin::Float64: Minimum curvature margin of the returned path, also sent as turn radius;0.0= off Default: 1.0turn_radius_headroom::Float64: Factor on the turn radius requested from the optimizer, on top of the gate's [-] Default: 1.0min_height::Float64: Ground clearance the returned path must have [m];0.0= off Default: 50.0candidate_elevation_margin::Float64: Margin abovemin_elevationa path's lowest point must have [deg] Default: 3.0
SimpleKiteControllers.TrajOptReopt — Type
Re-optimization while the tether grows (simple_opt_reelout.jl), part reopt of TrajOptSettings, section reopt: of the YAML file.
Fields
reopt_every_n_laps::Int64: Laps between re-optimizations Default: 2max_reopt::Int64: Max re-optimizations per run; bounds wall time Default: 4reopt_blocking::Bool: Halt the simulation while a re-optimization runs Default: truepath_blend_time::Float64: Time over which a new path is blended into the old one [s] Default: 4.0blend_max_retries::Int64: Fresh solves allowed when a reply is rejected by the blend or power gates Default: 3size_box_growth::Float64: Box sent with re-optimizations, as factor on the previous install's size;0= off Default: 1.3challenge_growth::Float64: Growth above which a power-losing reply is challenged by a cold solve;0= off Default: 1.1
SimpleKiteControllers.TrajOptReoptGates — Type
The extra gates of a re-optimization reply, part reopt_gates of TrajOptSettings, section reopt_gates: of the YAML file.
Fields
min_power_frac::Float64: Min fraction of the startup install's predicted power a reply must reach [-] Default: 0.3min_power_frac_prev::Float64: Min fraction of the previous install's predicted power a reply must reach [-] Default: 0.85power_gate_wind_min::Float64: Wind below which negative power predictions bypass the power gates [m/s] Default: 4.0max_size_growth::Float64: Max size growth of a reply relative to the previous install [-];0.0= off Default: 1.3blend_fold_margin::Float64: Fraction of the endpoints' min radius every blended path must clear [-] Default: 0.5
SimpleKiteControllers.turn_radius_lap_reelout — Function
turn_radius_lap_reelout(tos::TrajOptSettings, v_wind::Float64)Reel-out per lap [m] assumed for the startup turn-radius request, from a linear fit to measured data across wind speeds 4-9 m/s: 1.987 * v_wind + 14.18 (R² = 0.949).
Building a path
SimpleKiteControllers.figure_eight_path — Function
figure_eight_path(A, B, x0, y0, theta, num_points)Figure-of-eight reference path, a lemniscate of Gerono. Returns (x, y): azimuth and elevation setpoints in degrees.
A: width,B: heightx0,y0: center coordinates,theta: rotation angle [rad]
SimpleKiteControllers.resample_path — Function
resample_path(az, el, n) -> (az, el)Redistribute a closed path over n points equidistant in arc length, by linear interpolation between the given ones. A duplicated closing point is dropped, and the result is cyclic: it does not repeat its first point.
The metric is the guidance's own _dist, the flat-sky distance with the azimuth axis compressed by cos(elevation), since that is the metric attractor_distance and search_window are measured in.
Any path not built here needs this: calc_attractor turns search_window into an index half-width from the mean spacing, and its branch disambiguation looks for LOCAL minima of the distance array. Both assume points spaced evenly along the path, which a curve sampled evenly in some other parameter — a B-spline's s, say — is not.
SimpleKiteControllers.prepare_path — Function
prepare_path(az, el; resample = 0, up_loops = true) -> (az, el)Bring a closed path into the canonical form two paths must share before they can be interpolated point by point: resample points equidistant in arc length (resample_path), traversed in the up_loops direction, and rotated so that index 1 is the point of maximum azimuth.
The rotation is what makes index i of one path the counterpart of index i of the other. Without it, two paths resampled from different starting points are blended across a phase offset, which sweeps the reference around the pattern instead of morphing it. The azimuth extreme is used as the anchor because it is the same landmark _path_geometry decides the traversal direction on.
SimpleKiteControllers.blend_paths — Function
blend_paths(az0, el0, az1, el1, w) -> (az, el)Point-by-point interpolation between two closed paths, w = 0 giving the first and w = 1 the second. Both must be in the canonical form of prepare_path and of the same length; interpolating paths that are not aligned produces a curve that resembles neither.
Used to swap a re-optimized path in gradually: installing one in a single step is a step in the cross-track error, and the guidance answers a step with steering.
SimpleKiteControllers.lobe_lift — Function
lobe_lift(az, el; lift = 0.0, mode = "azimuth", az_full = 10.0, az_blend = 8.0)
-> Vector{Float64}Per-point elevation lift [deg] to be added to the closed path (az, el) [deg]: zero in the middle of the pattern and lift at the lobes, so the pattern is raised where that buys clearance instead of rigidly. Raising the whole lobe, top included, is a vertical translation of the part of the path that turns hardest, which is why this profile is nearly free.
mode selects the units of az_full and az_blend:
"azimuth"— zero inside|az| = az_full - az_blend,liftbeyondaz_full, smoothstep between, both in DEGREES."azimuth_frac"— the same, withaz_fullandaz_blendread as fractions of the path's OWN azimuth amplitude, so the profile keeps its place on a pattern that shrinks by a third as the tether grows.
Querying a path
SimpleKiteControllers.azimuth_frac — Function
azimuth_frac(az, az_lo, az_hi) -> Float64Where az [deg] lies across a pattern spanning az_lo … az_hi [deg], as a fraction of that pattern's own half-width: 0 at its centre — the crossing — and 1 at either lobe. A pattern with no azimuth extent gives 0.
Normalized rather than measured in degrees because the pattern shrinks by a third in azimuth as the tether grows, so a profile keyed on degrees walks out of it over a reel-out run while one keyed on this stays where it was put.
SimpleKiteControllers.azimuth_bin — Function
azimuth_bin(az, az_lo, az_hi, nbins) -> IntIndex in 1:nbins of the azimuth band az falls in, bin 1 covering the crossing and bin nbins the lobes. Bands are equal in azimuth_frac, so they keep their place on a shrinking pattern; anything past the lobe lands in the last bin.
SimpleKiteControllers.path_tangent — Function
path_tangent(fec::FigureEightController) -> Float64Direction [rad] in which the reference path is traversed at the closest point Q found by the last calc_attractor/navigate_fig8 call, in the same bearing convention as chi_set.
Useful as an entry reference when the kite is far from the path: the great-circle course to the attractor is degenerate when the kite sits almost directly above the pattern, where every attractor point is ~"straight down" and the sign of chi_set is numerical noise on the ±180° branch cut. The tangent at Q has no such degeneracy and encodes which way round the path is traversed, so an entry flown along it arrives moving in the right direction.
SimpleKiteControllers.path_normal — Function
path_normal(fec::FigureEightController, i) -> (az, el)Right-hand normal (azimuth, elevation) of path point i, in degrees of arc; tangent is a bearing.
SimpleKiteControllers.path_distance — Function
path_distance(az_path, el_path, azimuth, elevation) -> Float64Cross-track error [deg] of the point (azimuth, elevation) [deg] to the closed path (az_path, el_path) [deg]: the distance to its nearest point, in the same flat-sky metric calc_attractor reports dmin in. Unlike dmin it knows nothing about branches or the previous Q — it is a plain nearest-point distance, which is all a tracking SCORE needs, and it can be taken against a path that is not the one the guidance is steering for.
SimpleKiteControllers.path_turn_rate — Function
path_turn_rate(fec::FigureEightController, lead, speed; smooth = 0.0) -> Float64Course rate [rad/s] the reference path asks for lead degrees of arc ahead of the closest point Q of the last calc_attractor call, for a kite moving along it at speed [deg/s of arc]: the change of the path tangent per degree of arc, averaged over smooth degrees centred on that point (at least one segment each side), times speed. Same sign convention as chi_set/SysState.heading, so with the turn-rate law psi_dot = c1 * v_a * u_s the steering that flies this curvature open loop is path_turn_rate(...) / (c1 * v_a).
Checking a pattern
Whether a path can be flown: its turn radius, its height above ground and its size relative to the path it replaces.
SimpleKiteControllers.min_turn_radius — Function
min_turn_radius(l_tether, max_steering; c1=V3_TURN_RATE_C1)Smallest angular turn radius [deg] the kite can fly on the sphere at steering authority max_steering [-] and tether length l_tether [m].
From ψ̇ = c1·v_a·u_s and the kite's angular speed ω = v/L, the angular radius of curvature is ρ = ω/ψ̇ = 1/(L·c1·u_s) — the apparent wind speed cancels, so this is a property of the geometry and the steering authority alone.
SimpleKiteControllers.path_min_radius — Function
path_min_radius(fec::FigureEightController) -> Float64Tightest geodesic turn radius [deg] of the reference path — see path_radius_profile. Compare with min_turn_radius: a pattern tighter than the kite's minimum turn radius cannot be flown at any PID tuning.
Note that the tightest point of a lemniscate in this metric is not the lobe tip (where the radius is B²/(A·cos(el_center))) but the upper shoulder of each lobe, where the cos(elevation) compression of the azimuth axis bends the path hardest. Use path_radius_profile to see where.
SimpleKiteControllers.path_radius_profile — Function
path_radius_profile(fec::FigureEightController) -> Vector{Float64}Geodesic turn radius [deg] at every point of the reference path: the arc length divided by the turning angle, both measured on the unit sphere. Inf marks a locally straight point.
Computed in true spherical geometry rather than the flat-sky (azimuth·cos(elevation), elevation) approximation the guidance itself uses — the pattern spans tens of degrees, where the two differ materially (~15% at el_center = 60°).
SimpleKiteControllers.check_pattern_feasible — Function
check_pattern_feasible(fec, l_tether, max_steering; c1=V3_TURN_RATE_C1, prn=true)Compare the reference path's tightest curvature with the kite's minimum turn radius. Returns (; feasible, path_radius, kite_radius, margin) where margin is path_radius / kite_radius (needs to be ≥ 1, and comfortably so — the kite must also correct cross-track error while turning, which costs authority).
Pass the c1 matching the body_damping in use — see turn_rate_coeffs; the default is init's default damping.
SimpleKiteControllers.path_min_height — Function
path_min_height(fec::FigureEightController, l_tether) -> Float64Height above ground [m] of the lowest point of the reference path, flown at tether radius l_tether: l_tether * sin(elevation_min), the straight-tether relation. Real tether sag puts the kite slightly lower, so this is an upper bound on the clearance.
The SHORTEST tether is the worst case, the same way it is for check_pattern_feasible — a path specified in (azimuth, elevation) rises as the tether grows.
SimpleKiteControllers.check_pattern_height — Function
check_pattern_height(fec, l_tether, min_height; prn = true)Compare the reference path's lowest point with a ground-clearance floor path_min_height. Returns (; ok, height, elevation, min_height), with elevation the path's lowest elevation [deg] and height its height [m] at l_tether.
A clearance floor is not the same criterion as an elevation floor, and the two scale opposite ways: at a short tether a fixed height needs a high elevation, at a long one it permits a very low one. An externally optimized path makes the difference concrete — AWETrim constrains height (50 m by default) and not elevation at all, but it takes its clearance partly from reeling OUT within the lap, so the same curve installed at the anchor radius and flown at constant length can sit well below the floor it was designed under.
SimpleKiteControllers.pattern_size_growth — Function
pattern_size_growth(az_from, el_from, az_to, el_to) -> (; growth, az_ratio, el_ratio)How much larger a candidate reference path (az_to, el_to) is than the one it would replace (az_from, el_from), both in degrees: az_ratio is the ratio of the azimuth half-widths, el_ratio the ratio of the elevation spans (peak to peak), and growth the larger of the two. A value of 1 is the same size, above 1 is bigger, below 1 is smaller.
A path that SHRINKS as the tether grows is the normal course of a reel-out and is not what this measures; it is the sudden jump UP in size that marks a re-optimization reply from a different basin (see max_size_growth in TrajOptSettings). A degenerate from path with zero width or height gives Inf in that ratio.