Flight control

The two loops that steer the kite: the figure-of-eight guidance turns the kite's position into a commanded course, the course controller turns that course into steering and depower.

SimpleKiteControllers.FC_Settings — Type

Settings of the flight controller of a run, flown by examples/simple_fig8.jl, examples/simple_reelout.jl and their variants, loaded from a YAML file such as fc_settings.yaml. 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. fcs.pattern.f8_a. The field names are unique over all parts, so the constructors and apply_overrides! also take a field by its bare name: FC_Settings(; f8_a = 25.0), FC_Settings(fcs; f8_a = 25.0) for a modified copy.

Fields

  • course::FC_Course: Heading/course loop, entry state machine, depower on the pattern
  • feedforward::FC_FeedForward: Steering feed-forward from the path's curvature
  • pattern::FC_Pattern: Pattern geometry and attractor guidance
  • wind_ramp::FC_WindRamp: Wind schedule of depower and pattern size
  • winch::FC_Winch: Force-mode winch and force guards
  • reelout::FC_Reelout: Reel-out start and stop, phase-5 depower and path
  • low_wind::FC_LowWind: Low-wind schedule of the reel-out start
  • run::FC_Run: Simulation conditions and pass criteria
source
SimpleKiteControllers.FC_Course — Type

The heading/course loop of FC_Settings, section course: of the YAML file: the entry state machine, the depower flown on the pattern, the heading PID and its gain schedule, the heading/course feedback blend and the entry descent limiter. CourseControllerSettings is built from it.

Fields

  • park_time::Float64: Parking [s]: zero steering while init transients decay Default: 2.0

  • chi_dive::Float64: Dive course [deg]; beyond ±90 descends, < 0: rightmost entry Default: -85.0

  • chi_hold::Float64: Hold course [deg]: horizontal, so the kite arrives flat Default: -90.0

  • dive_el_margin::Float64: Dive ends this far above el_center [deg] Default: 7.0

  • hold_time::Float64: Duration of the hold [s] Default: 0.8

  • fig8_d_gate::Float64: Cross-track error [deg] for phase 3 -> 4; log only Default: 5.0

  • entry_gain::Float64: Factor on heading_p during the entry phases (dive and hold) Default: 0.25

  • entry_depower::Float64: Depower [-] held during the entry phases (dive and hold) Default: 0.34

  • depower_setpoint::Float64: Run depower [-]; the turn-rate law's operating point Default: 0.26

  • depower_blend_time::Float64: Ramp time [s] to a new depower target; 0 = hard switch Default: 4.0

  • heading_p::Float64: Gain at v_app_ref; only heading_p * v_app_ref matters Default: 0.1941

  • heading_i::Union{Bool, Float64}: Integral time [s], or false for none Default: false

  • heading_d::Float64: Derivative time [s], damps the initial transient Default: 0.12

  • heading_d_n::Float64: Derivative filter N: K*Td*s/(1 + s*Td/N) Default: 2.0

  • v_app_ref::Float64: Phase-3 apparent wind [m/s]; anchors gain schedule, lead Default: 27.0

  • v_app_min::Float64: Lower clamp on v_app, limits the gain boost [m/s] Default: 10.0

  • v_app_min_pattern::Float64: Extra v_app clamp [m/s] from phase 3 on; 0 = off Default: 0.0

  • v_kite_heading::Float64: [m/s] at/below: pure heading feedback Default: 5.0

  • v_kite_course::Float64: [m/s] at/above: pure course feedback; blended below Default: 10.0

  • fig8_pure_course::Bool: Course-only feedback from phase 3 on, ignoring v_kite_* Default: false

  • max_steering::Float64: Steering limit [-]; unstable above ~0.33 (loop), 0.375 (plant) Default: 0.32

  • entry_chi_max::Float64: Steepest off-path course [deg]; 90 = level, 180 = off Default: 95.0

  • entry_d_gate::Float64: Cross-track error [deg] below which the limiter is bypassed Default: 12.0

  • entry_d_blend::Float64: Blend band [deg] above entry_d_gate; 0 = hard switch Default: 4.0

  • entry_cut_margin::Float64: Band around ±180° [deg] using the latched sign Default: 30.0

source
SimpleKiteControllers.FC_FeedForward — Type

The steering feed-forward from the reference path's curvature, section feedforward: of FC_Settings's YAML file. Active from phase 4 on.

Fields

  • ff_gain::Float64: Gain on u_ff = psi_dot_path / (c1 * v_app); 0 = off Default: 0.0

  • ff_lead_time::Float64: Feed-forward look-ahead [s] past Q; ~ steering dead time Default: 0.45

  • ff_smooth::Float64: Arc [deg] the feed-forward averages the tangent over Default: 3.0

  • ff_tau::Float64: Feed-forward low-pass [s], incl. chord term; 0 = none Default: 0.2

  • ff_d_fade::Float64: Cross-track error [deg] of full feed-forward fade-out Default: 6.0

  • ff_err_fade::Float64: Course error [deg] of full feed-forward fade-out Default: 60.0

source
SimpleKiteControllers.FC_Pattern — Type

The pattern geometry and the attractor guidance, section pattern: of FC_Settings's YAML file, all angles in degrees; a SMALLER lemniscate is a TIGHTER one. FigureEightController is built from it.

Fields

  • f8_a::Float64: Width of the eight [deg] (azimuth spans +-f8_a) Default: 40.0

  • f8_b::Float64: Height of the eight [deg] (elevation spans +-f8_b/2) Default: 15.0

  • el_center::Float64: Centre elevation [deg]; lower: more margin, less energy Default: 26.0

  • attractor_dist::Float64: Arc Q -> attractor [deg]; the floor of a timed lead Default: 10.0

  • attractor_dist_ref_length::Float64: Tether length [m] where attractor_dist holds; floor ∝ 1/L; 0 = fixed Default: 0.0

  • attractor_lead_time::Float64: Attractor lead [s], 1-2 × attractor_dist; 0 = fixed Default: 0.0

  • reacquire_margin::Float64: How much closer [deg] a global point must be for Q to jump Default: 3.0

  • up_loops::Bool: Fly up-loops, not down-loops (reverses the path direction) Default: false

source
SimpleKiteControllers.FC_WindRamp — Type

The wind schedule, section wind_ramp: of FC_Settings's YAML file: course.depower_setpoint, pattern.f8_a and pattern.f8_b are flown up to wind_ramp_low, the *_high values from wind_ramp_high on, linear in between. See wind_schedule.

Fields

  • wind_ramp_low::Float64: Wind speed [m/s] below which no *_high value is blended in Default: 7.0

  • wind_ramp_high::Float64: Wind [m/s] from which *_high apply; linear below Default: 10.0

  • depower_high::Float64: Depower [-] from wind_ramp_high; NaN keeps depower_setpoint Default: NaN

  • f8_a_high::Float64: Pattern width [deg] held from wind_ramp_high on; NaN keeps f8_a Default: NaN

  • f8_b_high::Float64: Pattern height [deg] held from wind_ramp_high on; NaN keeps f8_b Default: NaN

source
SimpleKiteControllers.FC_LowWind — Type

The low-wind schedule of a reel-out run, section low_wind: of FC_Settings's YAML file: the starting tether length, the startup guess's centre elevation, the gain schedule's v_app_min and the phase-5 lift, one value per wind speed at low_wind_height, linear in between and held below the first. At and above the last wind speed the schedule is off and the settings files' own values are flown. Empty vectors (the default) switch it off. See low_wind_schedule.

Fields

  • low_wind_height::Float64: Height [m] of the wind speed the schedule is keyed on Default: 100.0

  • low_wind_speeds::Vector{Float64}: Wind speeds [m/s] at low_wind_height, ascending; empty = off Default: Float64[]

  • low_wind_l_tether::Vector{Float64}: Tether length [m] reel-out starts at, per wind speed Default: Float64[]

  • low_wind_guess_el_center::Vector{Float64}: Startup guess's centre elevation [deg] (guess_el_center) Default: Float64[]

  • low_wind_v_app_min::Vector{Float64}: Floor [m/s] of the gain schedule (v_app_min) Default: Float64[]

  • low_wind_el_offset_final::Vector{Float64}: Phase-5 path lift [deg] (el_offset_final) Default: Float64[]

source
SimpleKiteControllers.FC_Winch — Type

The winch settings of FC_Settings, section winch: of its YAML file: how compliant the force-mode winch is (see winch_force_gains) and the force guards of the entry and the first lap. The winch gains themselves, of both modes, and the reel-out law are in wc_settings.yaml.

Fields

  • compliance::Float64: Winch softness [-]: divides the force-mode gains; 0 = POSITION mode Default: 0.5

  • entry_f_min::Float64: Entry-guard force floor [N], phases 0-2; not WCSettings.f_low Default: 350.0

  • first_lap_force_frac::Float64: First-lap force limit / WCSettings.f_high; 1 = off Default: 1.0

source
SimpleKiteControllers.FC_Reelout — Type

The reel-out run of FC_Settings, section reelout: of its YAML file: when reel-out starts and stops, and the depower and path flown once it has stopped (phase 5). Only read by reel-out runs.

Fields

  • reelout_l_max::Float64: Tether length [m] at which reel-out stops and is held Default: 250.0

  • n_fig_eight::Int64: Figures of eight until reel-out stops (or reelout_l_max); 0 = off Default: 0

  • reelout_softstart::Float64: Ramp-up time [s] of the reel-out speed; 0 = off Default: 0.0

  • reelout_softstop::Float64: Soft-stop lead [s]: v_set -> 0 at reelout_l_max; 0 = off Default: 0.0

  • reelout_delay::Float64: Delay [s] from phase 3 to the reel-out start Default: 0.0

  • reelout_f_trigger::Float64: Latching force [N] that starts reel-out early; Inf = off Default: Inf

  • final_time::Float64: Time [s] in phase 5 before the run ends; Inf = full run Default: Inf

  • depower_final::Float64: Phase-5 depower [-]; tuned for 350 m tether, 6 m/s wind Default: 0.328

  • depower_final_max::Float64: Phase-5 force-limiter ceiling [-]; depower_final = off Default: 0.328

  • depower_final_f_target::Float64: Phase-5 limiter force [N]: criterion - lobe swing Default: 7500.0

  • depower_final_f_gain::Float64: Integrator gain [1/(N s)], phase-5 force limiter Default: 2.0e-5

  • depower_final_f_gain_stop::Float64: depower_final_f_gain during the soft stop Default: 2.0e-5

  • el_offset_final::Float64: Path lift [deg] from the reel-out stop latch on Default: 0.0

  • el_offset_lead::Float64: Lead [s] of the el_offset_final lift before reel-out ends Default: 0.0

  • final_margin_min::Float64: Min. phase-5 curvature margin [-], else blend back; 0 = off Default: 0.0

  • el_offset_wing::Float64: Lobe-only elevation lift [deg], ramped over azimuth; 0 = off Default: 0.0

  • el_offset_wing_az::Float64: Azimuth [deg] beyond which el_offset_wing is full Default: 10.0

  • el_offset_wing_blend::Float64: Ramp width [deg] below el_offset_wing_az; gap 0-3 Default: 8.0

  • el_offset_wing_mode::String: Wing-offset unit: azimuth [deg] or azimuth_frac Default: azimuth

source
SimpleKiteControllers.FC_Run — Type

The simulation conditions and the pass criteria of FC_Settings, section run: of its YAML file.

Fields

  • vsm_interval::Int64: Steps between VSM aero updates Default: 1

  • elevation::Float64: Elevation [deg] init settles at; in the settled-state cache key Default: 73.0

  • warmup_time::Float64: Unlogged warm-up [s] inside init (V3Kite's warmup!); 0 = off Default: 2.0

  • body_damping::Vector{Float64}: Per-axis damping, turn_rate_coeffs key Default: [0.0, 0.0, 40.0]

  • v_app_abort::Float64: Abort the run above this apparent wind speed [m/s] Default: 45.0

  • entry_time::Float64: Settle time [s] after park_time before statistics Default: 52.0

  • min_elevation::Float64: Elevation floor criterion [deg], evaluated over the WHOLE run Default: 10.0

  • min_span_frac::Float64: Min. size, fraction of f8_a (per side), f8_b (span) Default: 0.7

source
SimpleKiteControllers.fc_settings — Function
fc_settings(project = project_file()) -> String

Get the flight-controller (FC) settings filename from the system project, analogous to KiteUtils.wc_settings. Returns the value of the fc_settings field of the project's system section; project defaults to this package's own project_file rather than KiteUtils.PROJECT.

source
SimpleKiteControllers.wind_schedule — Function
wind_schedule(fcs::FC_Settings, v_wind) -> (; depower_setpoint, f8_a, f8_b)

What to fly on the pattern at wind speed v_wind [m/s, reference height]: each of depower_setpoint, f8_a, f8_b as set up to fcs.wind_ramp.wind_ramp_low, its *_high counterpart from fcs.wind_ramp.wind_ramp_high on, linear in between; a *_high that is NaN leaves its value alone. The depower is rounded to 0.01, the angles to 0.5°: the settled-geometry cache is keyed on the depower, so this costs one settle per step, not one per wind speed. Applied by apply_wind_schedule!.

source
SimpleKiteControllers.apply_wind_schedule! — Function
apply_wind_schedule!(fcs::FC_Settings, v_wind) -> FC_Settings

Overwrite fcs.course.depower_setpoint, fcs.pattern.f8_a and fcs.pattern.f8_b with wind_schedule(fcs, v_wind). Call it once, before anything is built from fcs: applied twice, the second call ramps from the first one's result.

source
SimpleKiteControllers.low_wind_schedule — Function
low_wind_schedule(fcs::FC_Settings, v_ref) -> Union{NamedTuple, Nothing}

What to fly at the wind speed v_ref [m/s] at fcs.low_wind.low_wind_height: (; l_tether, guess_el_center, v_app_min, el_offset_final), linear between the rows of fcs.low_wind (FC_LowWind) and the first row's values below it. nothing at and above the last row's wind speed, and for an empty schedule: the settings files' own values are flown there.

Keyed on the wind at height and not at h_ref, because that orders the sites by what the kite meets: Cabauw's 3 m/s is 5.8 m/s at 100 m, Maasvlakte's 3.5 m/s only 4.5 m/s (2026-10-02). Errors for vectors of unequal length or wind speeds that do not ascend.

source
SimpleKiteControllers.apply_low_wind_schedule! — Function
apply_low_wind_schedule!(fcs, tos, project_set; keep = ()) -> NamedTuple

Overwrite the starting tether length project_set.l_tether, tos.guess.guess_el_center, fcs.course.v_app_min and fcs.reelout.el_offset_final with low_wind_schedule at low_wind_reference. 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 being what was applied, or nothing when the schedule is off at this wind.

Warns when the last row differs from the settings files' own values: the schedule then steps at its last wind speed.

source

Course controller

The inner loop, with the entry state machine (park, dive, hold, transition, figure-of-eight).

SimpleKiteControllers.CourseController — Type
CourseController(ccs::CourseControllerSettings)

Stateful inner loop of the figure-of-eight flight controller: holds the heading/course PID and the entry state machine.

Entry state machine (0 park, 1 dive, 2 hold, 3 transition, 4 fig8), advanced at the start of each calc_steering call, never backwards: park -> dive at t >= ccs.park_time, dive -> hold at elevation <= ccs.el_center + ccs.dive_el_margin, hold -> transition ccs.hold_time later, transition -> fig8 at dmin < ccs.fig8_d_gate. Phase 5 ("final") is winch-triggered and set from outside with set_phase!; this ladder never reaches it on its own.

The fields from chi_cmd on hold values of the last calc_steering call.

Fields

  • ccs::CourseControllerSettings: The CourseControllerSettings it was built from

  • pid::DiscretePIDs.DiscretePID: Heading/course PID

  • phase::Int64: Entry state machine phase, 0-4 advanced by calc_steering, 5 by set_phase!

  • hold_start::Float64: [s] sim time the hold (phase 2) began, NaN before it does

  • entry_sign::Int64: Latched sign of the entry descent limiter at the ±180° cut; 0 = unset

  • chi_cmd::Float64: [rad] commanded course (post-limiter, post-override)

  • w_lim::Float64: [-] descent-limiter blend weight of the last calc_steering call

  • psi_prime::Float64: [rad] fused heading/course feedback angle of the last calc_steering call

  • w_course::Float64: [-] heading/course blend weight of the last calc_steering call

  • err::Float64: [rad] regulated error (psi_prime - chi_cmd) of the last calc_steering call

  • depower_target::Float64: Phase-ladder depower target, NaN before the first call

  • depower_from::Float64: [-] depower value the current blend started from

  • depower_t0::Float64: [s] sim time the current depower blend started

  • depower_cmd::Float64: [-] rel_depower commanded (post-blend)

  • u_ff::Float64: [-] feed-forward steering added to the PID output in the last calc_steering call

source
SimpleKiteControllers.CourseControllerSettings — Type
CourseControllerSettings

Settings of CourseController: the heading/course PID, the ψ' fusion and the gain schedule. All angles in radians unless noted; dt has no default, matching FigureEightSettings.

Fields

  • dt::Float64: Time step [s]

  • heading_p::Float64: Gain at v_app_ref; see fc_settings.yaml Default: 0.1941

  • heading_i::Union{Bool, Float64}: Integral time [s], or false for none Default: false

  • heading_d::Float64: Derivative time [s] Default: 0.12

  • heading_d_n::Float64: Derivative filter's maximum gain Default: 2.0

  • max_steering::Float64: Steering command limit [-]; also the PID's output clamp Default: 0.32

  • v_app_ref::Float64: Apparent wind speed [m/s] the gain schedule is anchored to Default: 27.0

  • v_app_min::Float64: Lower clamp on v_app, limits the gain boost [m/s] Default: 10.0

  • v_app_min_pattern::Float64: Extra v_app clamp [m/s] from phase 3 on; 0 = off Default: 0.0

  • entry_gain::Float64: Factor on heading_p while phase < 3 Default: 0.25

  • v_kite_heading::Float64: [m/s] at/below: pure heading feedback Default: 5.0

  • v_kite_course::Float64: [m/s] at/above: pure course feedback; blended below Default: 10.0

  • fig8_pure_course::Bool: Course-only feedback from phase >= 3 Default: false

  • course_offset::Float64: SysState.course has its zero pointing away from zenith; 0 for a source already in the bearing convention. Default: π

  • entry_chi_max::Float64: Steepest off-path course [deg]; 180 = off Default: 95.0

  • entry_d_gate::Float64: Cross-track error [deg] below which the limiter is bypassed Default: 12.0

  • entry_d_blend::Float64: Blend band [deg] above entry_d_gate Default: 4.0

  • entry_cut_margin::Float64: Band around ±180° [deg] using the latched sign Default: 30.0

  • chi_dive::Float64: Course commanded during the dive [deg] Default: -85.0

  • chi_hold::Float64: Course commanded during the hold [deg] Default: -90.0

  • park_time::Float64: Parking [s]: zero steering while transients decay Default: 2.0

  • hold_time::Float64: Duration of the hold [s] Default: 0.8

  • dive_el_margin::Float64: Dive ends this far above el_center [deg] Default: 7.0

  • el_center::Float64: Pattern-centre elevation [deg], the ladder's 1->2 threshold Default: 26.0

  • fig8_d_gate::Float64: Cross-track error [deg] below which phase 3 (transition) advances to phase 4 (fig8), the first time it is crossed Default: 5.0

  • depower_setpoint::Float64: Depower held during the pattern [-] Default: 0.26

  • entry_depower::Float64: Depower held during the ENTRY phases (dive and hold) [-] Default: 0.34

  • depower_final::Float64: Depower [-] flown in phase 5 (reel-out done) Default: 0.328

  • depower_blend_time::Float64: Seconds over which rel_depower ramps to a new phase-ladder target instead of stepping to it. 0 restores the hard switch. Default: 4.0

source
SimpleKiteControllers.calc_steering — Function
calc_steering(cc::CourseController, chi_set, heading, course;
              t, elevation, v_kite, v_app, dmin, tangent, gain_scale = 1.0,
              u_ff = 0.0, chi_ff = 0.0)

Inner loop of the figure-of-eight flight controller: raw guidance course chi_set [rad] in, (rel_steering, rel_depower, phase) out. heading/ course are SysState.heading/SysState.course [rad] (course shifted by ccs.course_offset before use); elevation [rad] and dmin [deg, the cross-track error] also come from SysState/the guidance, and tangent [rad] is the reference path direction at the closest point — dmin and tangent as scalars, so the controller never sees a FigureEightController. t is the sim time [s].

It first advances the entry state machine (cc.phase, see CourseController), whose phase drives everything below.

chi_set then passes the descent limiter — active only while dmin is above ccs.entry_d_gate, clamping steepness to ccs.entry_chi_max with the sign latched (cc.entry_sign) from tangent near the ±180° cut — then the open-loop override for phase in (1, 2) (ccs.chi_dive/ccs.chi_hold, constant regardless of guidance). The result becomes the feedback loop's reference chi_cmd.

The PID regulates err = ψ' - chi_cmd, where the feedback angle ψ' = heading + w_course * (course - heading) blends heading and course, with w_course going from 0 at ccs.v_kite_heading to 1 at ccs.v_kite_course by v_kite [m/s]; ccs.fig8_pure_course forces w_course = 1 from phase >= 3. The gain is scheduled by v_app [m/s] as K = heading_p * v_app_ref / max(v_app, v_app_min) (from phase 3 on also floored at v_app_min_pattern), by phase (entry_gain below 3, full gain from 3), and by gain_scale (default 1.0), a caller-supplied factor for what the schedule cannot see from here — the turn-rate gain c1 moving with the depower actually flown, when that is not ccs.depower_setpoint the loop was tuned at; the PID output is bypassed to 0.0 at phase == 0 (park), though it is still stepped so engagement stays bumpless.

u_ff [-] is a feed-forward steering (the path's own curvature through the turn-rate law, see FC_Settings.feedforward.ff_gain) added to the PID's output from phase >= 4 on (the transition flies the descent limiter, off the path where the curvature means nothing) and clamped with it to max_steering; the PID itself never sees it. chi_ff [rad] is subtracted from the commanded course over the same phases: the attractor is a chord ahead of the kite, and on a curve that chord sits kappa * lead / 2 off the tangent, so a kite exactly on the path reads a steady error the PD turns into the curvature steering — the feed-forward's job. Without this correction the two add and the kite overturns. rel_depower's TARGET is ccs.entry_depower during the dive and hold, ccs.depower_final at phase 5, ccs.depower_setpoint otherwise; a target change ramps rel_depower to it over ccs.depower_blend_time (0 steps instead), rather than at the phase-ladder's own boundary.

chi_cmd, the limiter weight, ψ', the blend weight and the regulated error are left on cc as chi_cmd/w_lim/psi_prime/ w_course/err for a caller that logs them.

source
SimpleKiteControllers.set_phase! — Function
set_phase!(cc::CourseController, phase)

Force the entry state machine to phase, e.g. reel-out finishing (examples/simple_reelout.jl's phase 5, "final") — the ladder inside calc_steering never reaches it on its own, 4 being its last state. Rejects a LOWER phase than the current one, preserving the ladder's "never backwards" invariant.

source

Figure-of-eight guidance

The outer loop: an attractor point runs ahead of the kite along the reference path.

SimpleKiteControllers.FigureEightSettings — Type
FigureEightSettings

Settings of the figure-of-eight guidance. All angles in degrees unless noted.

search_window and reacquire_dist keep the closest point Q continuous: Q is searched only within search_window of arc around its previous position, so it cannot jump to the far branch at the self-intersection, where the tangent is ~180° opposed. 0 disables the restriction. Above reacquire_dist of cross-track error the search goes global again, so a kite genuinely off-path is not trapped. branch_tol, branch_hysteresis and min_speed govern the second, weaker guard — disambiguating near-equal candidates by the current flight direction, with branch_hysteresis the alignment a challenger must beat the incumbent by before Q moves to it. Inside that band the tie goes to the candidate nearest the previous Q, which is what keeps the pick from oscillating where the kite runs parallel to the path and several minima sit within branch_tol. See calc_attractor.

q_rate_gain is the guard the two above cannot be: it bounds how far Q may move per step (that many times the arc the kite itself flew), so an argmin that swaps between two near-equal minima is walked to rather than teleported to. It is suspended only when the kite is genuinely off-path (beyond reacquire_dist, where the search is global from the start) — a reacquire_margin jump within an otherwise active window is rate-limited like any other candidate swap, since it shares aligned's 90° gate and can pick the far branch near the crossing just as easily. 0 disables it.

search_window is an absolute arc, and a path much smaller than twice it would leave no window at all: an optimized pattern at a long tether measures a few tens of degrees of arc in total, where a 45° half-width spans the whole curve and the continuity guard silently becomes a global search. search_window_max_frac caps the half-width at that fraction of the path's own arc length, so the guard scales down with the pattern instead of switching off. A window can also go stale — after a path is installed, or after an excursion that ended within reacquire_dist — and reacquire_margin is the second exit: Q leaves the window when the best point on the whole path is that much closer than the best inside it. Both searches are restricted to points aligned with the flight direction (see calc_attractor): near the crossing the nearest point of all is often the reverse branch, and taking it flips the commanded course by ~180°.

Fields

  • dt::Float64: Time step [s]

  • A::Float64: Width of the figure-eight [deg] Default: 30.0

  • B::Float64: Height of the figure-eight [deg] Default: 12.0

  • az_center::Float64: Azimuth of the path center [deg] Default: 0.0

  • el_center::Float64: Elevation of the path center [deg] Default: 60.0

  • theta::Float64: Rotation angle [rad] Default: 0.0

  • num_points::Int64: Number of points of the generated path Default: 361

  • attractor_distance::Float64: Arc distance from Q to the attractor [deg] Default: 7.0

  • up_loops::Bool: Fly upwards during the turns at large |azimuth| Default: true

  • branch_tol::Float64: Candidates within dmin + branch_tol are disambiguated [deg] Default: 3.0

  • branch_hysteresis::Float64: Alignment advantage [deg] a candidate needs to take Q Default: 10.0

  • q_rate_gain::Float64: Q may advance this many times the kite's own arc per step [-] Default: 2.0

  • min_speed::Float64: Minimum angular speed to trust the course estimate [deg/s] Default: 1.0

  • course_tau::Float64: Low-pass on the course estimate [s] Default: 0.5

  • search_window::Float64: Arc half-width of the local Q search [deg] Default: 45.0

  • search_window_max_frac::Float64: Cap on that half-width, fraction of the path [-] Default: 0.125

  • reacquire_dist::Float64: Cross-track error above which the search goes global [deg] Default: 25.0

  • reacquire_margin::Float64: How much better the global best may be before Q jumps [deg] Default: 3.0

source
SimpleKiteControllers.FigureEightController — Type
FigureEightController(fes::FigureEightSettings)

Stateful figure-of-eight guidance: holds the discretized reference path and the filtered course estimate used to disambiguate the two branches at the self-intersection.

Fields

  • fes::FigureEightSettings: The settings it was built from

  • az_path::Vector{Float64}: Azimuth of the path points [deg], cyclic (no duplicated end point)

  • el_path::Vector{Float64}: Elevation of the path points [deg]

  • seg_len::Vector{Float64}: Arc length of segment i -> i+1 [deg]

  • tangent::Vector{Float64}: Path direction at point i [rad] (chi convention)

  • last_idx::Int64: Index of the last closest point Q

  • course::Float64: Filtered course estimate [rad]

  • speed::Float64: Angular speed estimate [deg/s]

  • fx::Float64: Course filter state (elevation component)

  • fy::Float64: Course filter state (azimuth component)

  • prev_az::Float64: Kite azimuth of the previous call [deg]

  • prev_el::Float64: Kite elevation of the previous call [deg]

  • has_prev::Bool: Whether prev_az and prev_el hold a position yet

source
SimpleKiteControllers.navigate_fig8 — Function
navigate_fig8(fec::FigureEightController, azimuth, elevation)

azimuth/elevation in radians (as in SysState). Returns (chi_set, az_attr, el_attr, dmin): the desired flight direction [rad] towards the attractor point by great-circle navigation, the attractor position [deg], and the cross-track error [deg].

chi_set is directly comparable to SysState.heading — same zero, same sign (see the convention block at the top of this file).

source
SimpleKiteControllers.calc_attractor — Function
calc_attractor(fec::FigureEightController, azimuth, elevation)

All angles in degrees. Find the closest path point Q and return (az_attr, el_attr, dmin): the attractor point attractor_distance degrees of arc ahead of Q along the path, and the cross-track error.

Near the self-intersection of the figure-of-eight two path branches are (almost) equally close. Two mechanisms keep Q on the right one:

  1. Flight direction, as a filter (min_speed): once the course estimate is trusted, only points whose tangent is within 90° of it can be Q at all. Index distance alone cannot separate the branches — at the crossing they come within a few tens of points of each other on a small path — but their tangents are ~180° apart there. Nothing qualifying leaves the plain nearest point.
  2. Continuity (search_window): Q is searched only within a window of arc around the previous Q, so it advances along the path instead of teleporting to the far branch. This is the primary guard; without it the commanded course flips by ~180° at every crossing of the self-intersection. The window is dropped beyond reacquire_dist from the path, so a genuinely off-path kite re-acquires globally, and its half-width is capped at search_window_max_frac of the path's arc length, so a small pattern keeps a window rather than losing the guard to a window wider than the curve.
  3. Flight direction (branch_tol, branch_hysteresis): among the remaining near-equal candidates, the branch whose tangent needs the smaller heading change wins — but only if it wins by branch_hysteresis, and inside that band the candidate nearest the previous Q is kept instead. Without the hysteresis the pick oscillates wherever the kite runs nearly parallel to the path, since the incumbent is re-derived from this step's distances rather than carried over (measured 2026-08-20; see the code). It only ever sees the far branch when the search is global — inside a window the far branch is not a candidate at all, and its ~180° of tangent difference is far outside the band.
source
SimpleKiteControllers.path_chord_offset — Function
path_chord_offset(fec::FigureEightController) -> Float64

Angle [rad] between the path tangent at the closest point Q of the last calc_attractor call and the great-circle course from Q to the attractor attractor_distance of arc ahead of it: what chi_set would read off the tangent for a kite sitting exactly on the path at Q. Positive where the path turns positive. A steering feed-forward that flies the path's curvature subtracts this from the commanded course, otherwise the guidance's own chord asks the PD for the same turn a second time — see calc_steering's chi_ff.

source
SimpleKiteControllers.set_path_center! — Function
set_path_center!(fec::FigureEightController, az_center, el_center)

Move the reference path to a new center [deg] and rebuild it in place. Intended for walking the center gradually from the capture elevation down to the force-optimal one — a large step demands a heading change the guidance cannot capture smoothly.

source
SimpleKiteControllers.set_path! — Function
set_path!(fec::FigureEightController, az, el; resample = 0,
          up_loops = fec.fes.up_loops)

Install an externally supplied closed path [deg] — from a trajectory optimizer, say — in place of the lemniscate. resample > 0 first redistributes it over that many equidistant points (resample_path), which any path not built here needs; 0 takes the points as given and throws if any of them repeats its predecessor.

The shape and center fields of fes are NOT updated: they describe the lemniscate that is no longer installed, and a later set_path_center! would rebuild it and discard this path. up_loops is, since the traversal direction of what was installed is a property of the path.

The course filter keeps running on purpose — it is what disambiguates the two branches at the crossing, and resetting it mid-flight loses branch lock for course_tau seconds — while the closest-point index is remapped to the point of the NEW path nearest the old Q AND on the same branch, so the local search window stays where the kite actually is.

Called every step of a gradual swap (examples/simple_opt_reelout.jl's path_blend_time), this remap has no calc_attractor guard behind it — aligned, branch_hysteresis and q_rate_gain all operate on last_idx after it runs, so a bad pick here reaches fec.last_idx outright. Near the self-intersection the two branches are close in position, and as the blended curve reshapes step by step the nearer one can swap between them; a plain Euclidean nearest-point search takes whichever is closer that step, same failure as an unranked argmin in calc_attractor. The only read of "the kite's own branch" available here — set_path! has no live position or course estimate — is the OLD last_idx's tangent, and it is used twice:

  1. points whose tangent is more than 90° from it are not candidates at all (a reversed traversal, or the far arm of a lobe);
  2. among the local minima within branch_tol of the nearest remaining point, the one whose tangent needs the smallest turn from it wins.

The second is what separates the branches AT the crossing: they meet there at the pattern's crossing angle — 25-70° of heading measured 2026-09-18 at 9 m/s (archive 2026-09-18_214437), both ascending — well inside the 90° gate, and both pass within a fraction of a degree of an old Q that sits on the intersection. With the gate alone, a blend step at t = 66.14 s of that run swapped Q to the other branch: the attractor jumped 9° of azimuth in one step, the guidance steered the kite back into the lobe it had just flown, and the heading-range criterion caught the extra loop. On a blend step the kite's own branch is a near copy of the old one (effort ~0°); the other is the crossing angle away. Falls back to the plain nearest point only if nothing is aligned.

source
SimpleKiteControllers.attractor_distance — Function
attractor_distance(fcs::FC_Settings, v_app, l_tether) -> Float64

The attractor lead [deg] to fly at apparent wind v_app [m/s] and tether length l_tether [m]: the attractor_floor while fcs.pattern.attractor_lead_time is off, otherwise the arc that takes attractor_lead_time seconds to fly, v_app floored at fcs.course.v_app_min and the result clamped to [floor, 2 * floor]. Pure kinematics, no plant: the caller writes it into FigureEightSettings.attractor_distance before each navigate_fig8.

source

Turn-rate law

The identified turn-rate coefficients c1, c2 and their lookup table.

SimpleKiteControllers.turn_rate_coeffs — Function
turn_rate_coeffs(body_damping, depower; interpolate=true, table) -> (; c1, c2, delay, v_app, dead_time, kite_lag, interpolated)

Look up the V3 turn-rate-law coefficients for a given body_damping and depower_setpoint, from data/turn_rate_coeffs.yaml (V3_TURN_RATE_COEFFS shows its grid points).

  • An exact (body_damping, depower) hit returns that row's values unchanged, interpolated = false. A row whose own identification did not pass (outcome other than :sweep_done/:time_limit, or too much scatter) throws instead of returning it — a failed sweep is recorded, never looked up as if it were data. Nothing else disqualifies a row: identification quality is the only bar, and a row identified at its own tether length or timestep counts like any other.
  • Between two grid points of the same body_damping, c1 is interpolated log-linearly (it decays close to exponentially with depower) and c2/delay linearly; delay is then rounded up to a multiple of the identification dt, so an interpolated dead time is never optimistic. Non-passing rows are never used as neighbours. interpolate = false disables this and throws instead.
  • Outside the identified depower range for that damping, or for a body_damping with no rows at all, this throws rather than extrapolating or guessing — re-identify by running examples/build_turn_rate_table.jl, which flies the missing cells and appends the rows itself.

v_app [m/s] is the mean apparent wind speed of the flights the row was identified at, interpolated linearly like delay; NaN for a row identified before it was recorded. The dead time falls with the airspeed, so delay holds at that v_app only (docs/course_loop_stability.md).

dead_time and kite_lag [s] split delay into a dead time and a first-order lag of the kite, identified on the same flights at the same v_app (joint_delay_lag_fit, in examples/build_turn_rate_table.jl); interpolated linearly like delay, NaN for a row without them.

table defaults to the session's table (loaded by reload_turn_rate_table!); pass another TurnRateTable (_load_turn_rate_table(project)) to look up a second table without replacing the session's, e.g. one for the controller and one for the path planning in examples/simple_opt_reelout.jl.

Both arguments matter. Depowering 0.25 → 0.55 costs a factor 2.95 of steering authority and raises the steering dead time from 0.03 s to 0.55 s. Body damping is never interpolated across: it is a 3-vector with a violently nonlinear effect on c1, so a body_damping with no identified rows throws rather than guessing from a nearby one.

source
SimpleKiteControllers.try_turn_rate_coeffs — Function
try_turn_rate_coeffs(fcs; consequence = "flying WITHOUT the feasibility check",
                     info = true) -> NamedTuple or nothing

turn_rate_coeffs at fcs.run.body_damping and fcs.course.depower_setpoint, or nothing, with a warning ending in consequence, when the table cannot serve that cell. turn_rate_coeffs refuses to extrapolate (by design: c1 moves violently with both arguments), so a caller that can run on unadvised — a deliberate off-grid run — uses this instead of aborting. Errors other than its ArgumentError are rethrown. info = true logs the coefficients that were found.

source
SimpleKiteControllers.turn_rate_depower_range — Function
turn_rate_depower_range(body_damping; table) -> (lo, hi)

The depower interval turn_rate_coeffs can serve for body_damping without throwing: the lowest and highest USABLE row (non-passing rows do not count, exactly as they are never interpolation neighbours). For a caller whose depower can leave the table — the phase-5 force limiter integrates up to depower_final_max, above the identified grid — so it can saturate its lookup at the edge instead of losing the coefficient altogether. Throws the same ArgumentError as turn_rate_coeffs for a damping with fewer than two usable rows, since nothing can be interpolated there either. table as in turn_rate_coeffs.

source
SimpleKiteControllers.reload_turn_rate_table! — Function
reload_turn_rate_table!(project = project_file())

Re-read the system project's turn_rate_coeffs file (turn_rate_coeffs_file) and refresh turn_rate_coeffs, V3_TURN_RATE_COEFFS, V3_TURN_RATE_C1 and V3_TURN_RATE_C2 from it. The table is otherwise read only at package load, against the default project — call this after examples/build_turn_rate_table.jl appends rows in the same session, instead of restarting.

If the [0,0,40]/0.25 lookup behind V3_TURN_RATE_C1/C2 throws, this function warns and leaves them at their previous value (NaN before the first successful load): a data-quality problem in one grid cell must never become a load-time failure, since this runs from __init__. turn_rate_coeffs still throws normally for any other caller asking for that combination.

source
SimpleKiteControllers.stack_fits — Function
stack_fits(fits, field::Symbol; skip = 0) -> Vector{Float64}

The field of every fit in fits (one identification window each), the first skip samples of each dropped — the samples a delay shift of up to skip leaves without input — concatenated into one series for a joint fit.

source