Flight-path geometry

Reference paths are closed curves in azimuth and elevation [deg].

Externally optimized flight path

SimpleKiteControllers.TrajOptSettings — Type
TrajOptSettings

Settings 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 sent
  • guess::TrajOptGuess: The initial guess the startup solve starts from
  • seed::TrajOptSeed: The depower seed of the solve
  • box::TrajOptBox: The pattern box the optimizer solves under
  • gates::TrajOptGates: The gates every returned path must pass
  • reopt::TrajOptReopt: Re-optimization while the tether grows
  • reopt_gates::TrajOptReoptGates: The extra gates of a re-optimization reply
source
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:8000

  • opt_failure_cache::Bool: Skip optimizer requests recorded as failed before (OPT_FAILURE_CACHE) Default: true

  • opt_success_cache::Bool: Replay previously applied optimizer results (OPT_CHAIN_CACHE) Default: true

  • opt_warm_start_awe_trim::Float64: use_awe_trim of a warm-up solve sent before the startup request; 0.0 = off Default: 0.0

  • opt_awe_trim::Float64: use_awe_trim sent to AWETrim; negative follows wc.use_awe_trim Default: -1.0

  • free_speed_reference_points::Int64: Lengths of the post-run free_speed reference power solve; 0 = off Default: 0

source
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.0

  • guess_b::Float64: Guess height; elevation spans guess_b peak to peak [deg] Default: 12.0

  • guess_el_center::Float64: Centre elevation of the guess [deg] Default: 26.0

  • guess_el_center_high::Float64: Guess centre elevation at and above guess_el_center_wind_ref [deg]; 0.0 = off Default: 0.0

  • guess_el_center_wind_ref::Float64: Wind speed at and above which guess_el_center_high is used [m/s] Default: 0.0

  • startup_retry_el_offsets::Vector{Float64}: Guess elevation offsets tried in order when the startup solve fails [deg] Default: Float64[]

source
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 seed l_dp on AWETrim's scale [m] Default: 1.6

  • input_depower_wind_ref::Float64: Wind speed at which input_depower is the seed [m/s] Default: 7.0

  • input_depower_per_wind::Float64: Tape length added to the seed per m/s above input_depower_wind_ref [m/(m/s)] Default: 0.0

  • input_depower_seed_max::Float64: Soft ceiling on the ramped depower seed [m]; 0.0 = AWETrim's hard bound only Default: 0.0

source
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.0

  • pattern_azimuth_max_high::Float64: Azimuth half-width limit at and above the high-wind step [deg]; 0.0 = off Default: 0.0

  • pattern_elevation_amplitude_max::Float64: Largest (RMS) elevation half-span of the optimized figure [deg]; 0.0 = off Default: 0.0

  • pattern_elevation_amplitude_max_high::Float64: Elevation half-span cap at and above the high-wind step [deg]; 0.0 = off Default: 0.0

  • pattern_elevation_amplitude_max_wind_ref::Float64: Wind speed aloft at and above which the high-wind caps apply [m/s] Default: 0.0

  • pattern_elevation_amplitude_max_wind_height::Float64: Height the high-wind step's wind speed is measured at [m]; 0.0 = ground wind Default: 0.0

  • pattern_symmetric::Bool: Force a mirror-symmetric figure-eight Default: false

  • pattern_climb_angle_max::Float64: Steepest climb angle of the optimized path [deg]; 0.0 = off Default: 0.0

source
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.0

  • turn_radius_headroom::Float64: Factor on the turn radius requested from the optimizer, on top of the gate's [-] Default: 1.0

  • min_height::Float64: Ground clearance the returned path must have [m]; 0.0 = off Default: 50.0

  • candidate_elevation_margin::Float64: Margin above min_elevation a path's lowest point must have [deg] Default: 3.0

source
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: 2

  • max_reopt::Int64: Max re-optimizations per run; bounds wall time Default: 4

  • reopt_blocking::Bool: Halt the simulation while a re-optimization runs Default: true

  • path_blend_time::Float64: Time over which a new path is blended into the old one [s] Default: 4.0

  • blend_max_retries::Int64: Fresh solves allowed when a reply is rejected by the blend or power gates Default: 3

  • size_box_growth::Float64: Box sent with re-optimizations, as factor on the previous install's size; 0 = off Default: 1.3

  • challenge_growth::Float64: Growth above which a power-losing reply is challenged by a cold solve; 0 = off Default: 1.1

source
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.3

  • min_power_frac_prev::Float64: Min fraction of the previous install's predicted power a reply must reach [-] Default: 0.85

  • power_gate_wind_min::Float64: Wind below which negative power predictions bypass the power gates [m/s] Default: 4.0

  • max_size_growth::Float64: Max size growth of a reply relative to the previous install [-]; 0.0 = off Default: 1.3

  • blend_fold_margin::Float64: Fraction of the endpoints' min radius every blended path must clear [-] Default: 0.5

source
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).

source

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: height
  • x0, y0: center coordinates, theta: rotation angle [rad]
source
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.

source
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.

source
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.

source
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, lift beyond az_full, smoothstep between, both in DEGREES.
  • "azimuth_frac" — the same, with az_full and az_blend read 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.
source

Querying a path

SimpleKiteControllers.azimuth_frac — Function
azimuth_frac(az, az_lo, az_hi) -> Float64

Where 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.

source
SimpleKiteControllers.azimuth_bin — Function
azimuth_bin(az, az_lo, az_hi, nbins) -> Int

Index 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.

source
SimpleKiteControllers.path_tangent — Function
path_tangent(fec::FigureEightController) -> Float64

Direction [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.

source
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.

source
SimpleKiteControllers.path_distance — Function
path_distance(az_path, el_path, azimuth, elevation) -> Float64

Cross-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.

source
SimpleKiteControllers.path_turn_rate — Function
path_turn_rate(fec::FigureEightController, lead, speed; smooth = 0.0) -> Float64

Course 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).

source

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.

source
SimpleKiteControllers.path_min_radius — Function
path_min_radius(fec::FigureEightController) -> Float64

Tightest 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.

source
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°).

source
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.

source
SimpleKiteControllers.path_min_height — Function
path_min_height(fec::FigureEightController, l_tether) -> Float64

Height 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.

source
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.

source
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.

source