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 patternfeedforward::FC_FeedForward: Steering feed-forward from the path's curvaturepattern::FC_Pattern: Pattern geometry and attractor guidancewind_ramp::FC_WindRamp: Wind schedule of depower and pattern sizewinch::FC_Winch: Force-mode winch and force guardsreelout::FC_Reelout: Reel-out start and stop, phase-5 depower and pathlow_wind::FC_LowWind: Low-wind schedule of the reel-out startrun::FC_Run: Simulation conditions and pass criteria
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.0chi_dive::Float64: Dive course [deg]; beyond ±90 descends, < 0: rightmost entry Default: -85.0chi_hold::Float64: Hold course [deg]: horizontal, so the kite arrives flat Default: -90.0dive_el_margin::Float64: Dive ends this far aboveel_center[deg] Default: 7.0hold_time::Float64: Duration of the hold [s] Default: 0.8fig8_d_gate::Float64: Cross-track error [deg] for phase 3 -> 4; log only Default: 5.0entry_gain::Float64: Factor onheading_pduring the entry phases (dive and hold) Default: 0.25entry_depower::Float64: Depower [-] held during the entry phases (dive and hold) Default: 0.34depower_setpoint::Float64: Run depower [-]; the turn-rate law's operating point Default: 0.26depower_blend_time::Float64: Ramp time [s] to a new depower target; 0 = hard switch Default: 4.0heading_p::Float64: Gain atv_app_ref; onlyheading_p * v_app_refmatters Default: 0.1941heading_i::Union{Bool, Float64}: Integral time [s], orfalsefor none Default: falseheading_d::Float64: Derivative time [s], damps the initial transient Default: 0.12heading_d_n::Float64: Derivative filter N:K*Td*s/(1 + s*Td/N)Default: 2.0v_app_ref::Float64: Phase-3 apparent wind [m/s]; anchors gain schedule, lead Default: 27.0v_app_min::Float64: Lower clamp on v_app, limits the gain boost [m/s] Default: 10.0v_app_min_pattern::Float64: Extrav_appclamp [m/s] from phase 3 on; 0 = off Default: 0.0v_kite_heading::Float64: [m/s] at/below: pure heading feedback Default: 5.0v_kite_course::Float64: [m/s] at/above: pure course feedback; blended below Default: 10.0fig8_pure_course::Bool: Course-only feedback from phase 3 on, ignoringv_kite_*Default: falsemax_steering::Float64: Steering limit [-]; unstable above ~0.33 (loop), 0.375 (plant) Default: 0.32entry_chi_max::Float64: Steepest off-path course [deg]; 90 = level, 180 = off Default: 95.0entry_d_gate::Float64: Cross-track error [deg] below which the limiter is bypassed Default: 12.0entry_d_blend::Float64: Blend band [deg] aboveentry_d_gate; 0 = hard switch Default: 4.0entry_cut_margin::Float64: Band around ±180° [deg] using the latched sign Default: 30.0
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 onu_ff = psi_dot_path / (c1 * v_app); 0 = off Default: 0.0ff_lead_time::Float64: Feed-forward look-ahead [s] past Q; ~ steering dead time Default: 0.45ff_smooth::Float64: Arc [deg] the feed-forward averages the tangent over Default: 3.0ff_tau::Float64: Feed-forward low-pass [s], incl. chord term; 0 = none Default: 0.2ff_d_fade::Float64: Cross-track error [deg] of full feed-forward fade-out Default: 6.0ff_err_fade::Float64: Course error [deg] of full feed-forward fade-out Default: 60.0
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.0f8_b::Float64: Height of the eight [deg] (elevation spans +-f8_b/2) Default: 15.0el_center::Float64: Centre elevation [deg]; lower: more margin, less energy Default: 26.0attractor_dist::Float64: Arc Q -> attractor [deg]; the floor of a timed lead Default: 10.0attractor_dist_ref_length::Float64: Tether length [m] whereattractor_distholds; floor ∝ 1/L; 0 = fixed Default: 0.0attractor_lead_time::Float64: Attractor lead [s], 1-2 ×attractor_dist; 0 = fixed Default: 0.0reacquire_margin::Float64: How much closer [deg] a global point must be for Q to jump Default: 3.0up_loops::Bool: Fly up-loops, not down-loops (reverses the path direction) Default: false
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*_highvalue is blended in Default: 7.0wind_ramp_high::Float64: Wind [m/s] from which*_highapply; linear below Default: 10.0depower_high::Float64: Depower [-] fromwind_ramp_high;NaNkeepsdepower_setpointDefault: NaNf8_a_high::Float64: Pattern width [deg] held fromwind_ramp_highon;NaNkeepsf8_aDefault: NaNf8_b_high::Float64: Pattern height [deg] held fromwind_ramp_highon;NaNkeepsf8_bDefault: NaN
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.0low_wind_speeds::Vector{Float64}: Wind speeds [m/s] atlow_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[]
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.5entry_f_min::Float64: Entry-guard force floor [N], phases 0-2; notWCSettings.f_lowDefault: 350.0first_lap_force_frac::Float64: First-lap force limit /WCSettings.f_high; 1 = off Default: 1.0
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.0n_fig_eight::Int64: Figures of eight until reel-out stops (orreelout_l_max); 0 = off Default: 0reelout_softstart::Float64: Ramp-up time [s] of the reel-out speed; 0 = off Default: 0.0reelout_softstop::Float64: Soft-stop lead [s]:v_set-> 0 atreelout_l_max; 0 = off Default: 0.0reelout_delay::Float64: Delay [s] from phase 3 to the reel-out start Default: 0.0reelout_f_trigger::Float64: Latching force [N] that starts reel-out early;Inf= off Default: Inffinal_time::Float64: Time [s] in phase 5 before the run ends;Inf= full run Default: Infdepower_final::Float64: Phase-5 depower [-]; tuned for 350 m tether, 6 m/s wind Default: 0.328depower_final_max::Float64: Phase-5 force-limiter ceiling [-];depower_final= off Default: 0.328depower_final_f_target::Float64: Phase-5 limiter force [N]: criterion - lobe swing Default: 7500.0depower_final_f_gain::Float64: Integrator gain [1/(N s)], phase-5 force limiter Default: 2.0e-5depower_final_f_gain_stop::Float64:depower_final_f_gainduring the soft stop Default: 2.0e-5el_offset_final::Float64: Path lift [deg] from the reel-out stop latch on Default: 0.0el_offset_lead::Float64: Lead [s] of theel_offset_finallift before reel-out ends Default: 0.0final_margin_min::Float64: Min. phase-5 curvature margin [-], else blend back; 0 = off Default: 0.0el_offset_wing::Float64: Lobe-only elevation lift [deg], ramped over azimuth; 0 = off Default: 0.0el_offset_wing_az::Float64: Azimuth [deg] beyond whichel_offset_wingis full Default: 10.0el_offset_wing_blend::Float64: Ramp width [deg] belowel_offset_wing_az; gap 0-3 Default: 8.0el_offset_wing_mode::String: Wing-offset unit:azimuth[deg] orazimuth_fracDefault: azimuth
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: 1elevation::Float64: Elevation [deg] init settles at; in the settled-state cache key Default: 73.0warmup_time::Float64: Unlogged warm-up [s] insideinit(V3Kite'swarmup!); 0 = off Default: 2.0body_damping::Vector{Float64}: Per-axis damping,turn_rate_coeffskey Default: [0.0, 0.0, 40.0]v_app_abort::Float64: Abort the run above this apparent wind speed [m/s] Default: 45.0entry_time::Float64: Settle time [s] afterpark_timebefore statistics Default: 52.0min_elevation::Float64: Elevation floor criterion [deg], evaluated over the WHOLE run Default: 10.0min_span_frac::Float64: Min. size, fraction off8_a(per side),f8_b(span) Default: 0.7
SimpleKiteControllers.fc_settings — Function
fc_settings(project = project_file()) -> StringGet 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.
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!.
SimpleKiteControllers.apply_wind_schedule! — Function
apply_wind_schedule!(fcs::FC_Settings, v_wind) -> FC_SettingsOverwrite 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.
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.
SimpleKiteControllers.low_wind_reference — Function
low_wind_reference(fcs::FC_Settings, project_set) -> Float64The wind speed [m/s] low_wind_schedule is keyed on: the project's v_wind (at h_ref, after any wind-speed override) scaled to fcs.low_wind.low_wind_height by the project's own profile law.
SimpleKiteControllers.apply_low_wind_schedule! — Function
apply_low_wind_schedule!(fcs, tos, project_set; keep = ()) -> NamedTupleOverwrite 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.
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: TheCourseControllerSettingsit was built frompid::DiscretePIDs.DiscretePID: Heading/course PIDphase::Int64: Entry state machine phase, 0-4 advanced bycalc_steering, 5 byset_phase!hold_start::Float64: [s] sim time the hold (phase 2) began,NaNbefore it doesentry_sign::Int64: Latched sign of the entry descent limiter at the ±180° cut; 0 = unsetchi_cmd::Float64: [rad] commanded course (post-limiter, post-override)w_lim::Float64: [-] descent-limiter blend weight of the lastcalc_steeringcallpsi_prime::Float64: [rad] fused heading/course feedback angle of the lastcalc_steeringcallw_course::Float64: [-] heading/course blend weight of the lastcalc_steeringcallerr::Float64: [rad] regulated error (psi_prime - chi_cmd) of the lastcalc_steeringcalldepower_target::Float64: Phase-ladder depower target,NaNbefore the first calldepower_from::Float64: [-] depower value the current blend started fromdepower_t0::Float64: [s] sim time the current depower blend starteddepower_cmd::Float64: [-]rel_depowercommanded (post-blend)u_ff::Float64: [-] feed-forward steering added to the PID output in the lastcalc_steeringcall
SimpleKiteControllers.CourseControllerSettings — Type
CourseControllerSettingsSettings 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 atv_app_ref; seefc_settings.yamlDefault: 0.1941heading_i::Union{Bool, Float64}: Integral time [s], orfalsefor none Default: falseheading_d::Float64: Derivative time [s] Default: 0.12heading_d_n::Float64: Derivative filter's maximum gain Default: 2.0max_steering::Float64: Steering command limit [-]; also the PID's output clamp Default: 0.32v_app_ref::Float64: Apparent wind speed [m/s] the gain schedule is anchored to Default: 27.0v_app_min::Float64: Lower clamp onv_app, limits the gain boost [m/s] Default: 10.0v_app_min_pattern::Float64: Extrav_appclamp [m/s] from phase 3 on; 0 = off Default: 0.0entry_gain::Float64: Factor onheading_pwhilephase < 3Default: 0.25v_kite_heading::Float64: [m/s] at/below: pure heading feedback Default: 5.0v_kite_course::Float64: [m/s] at/above: pure course feedback; blended below Default: 10.0fig8_pure_course::Bool: Course-only feedback fromphase >= 3Default: falsecourse_offset::Float64:SysState.coursehas 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.0entry_d_gate::Float64: Cross-track error [deg] below which the limiter is bypassed Default: 12.0entry_d_blend::Float64: Blend band [deg] aboveentry_d_gateDefault: 4.0entry_cut_margin::Float64: Band around ±180° [deg] using the latched sign Default: 30.0chi_dive::Float64: Course commanded during the dive [deg] Default: -85.0chi_hold::Float64: Course commanded during the hold [deg] Default: -90.0park_time::Float64: Parking [s]: zero steering while transients decay Default: 2.0hold_time::Float64: Duration of the hold [s] Default: 0.8dive_el_margin::Float64: Dive ends this far aboveel_center[deg] Default: 7.0el_center::Float64: Pattern-centre elevation [deg], the ladder's 1->2 threshold Default: 26.0fig8_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.0depower_setpoint::Float64: Depower held during the pattern [-] Default: 0.26entry_depower::Float64: Depower held during the ENTRY phases (dive and hold) [-] Default: 0.34depower_final::Float64: Depower [-] flown in phase 5 (reel-out done) Default: 0.328depower_blend_time::Float64: Seconds over whichrel_depowerramps to a new phase-ladder target instead of stepping to it.0restores the hard switch. Default: 4.0
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.
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.
Figure-of-eight guidance
The outer loop: an attractor point runs ahead of the kite along the reference path.
SimpleKiteControllers.FigureEightSettings — Type
FigureEightSettingsSettings 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.0B::Float64: Height of the figure-eight [deg] Default: 12.0az_center::Float64: Azimuth of the path center [deg] Default: 0.0el_center::Float64: Elevation of the path center [deg] Default: 60.0theta::Float64: Rotation angle [rad] Default: 0.0num_points::Int64: Number of points of the generated path Default: 361attractor_distance::Float64: Arc distance from Q to the attractor [deg] Default: 7.0up_loops::Bool: Fly upwards during the turns at large |azimuth| Default: truebranch_tol::Float64: Candidates within dmin + branch_tol are disambiguated [deg] Default: 3.0branch_hysteresis::Float64: Alignment advantage [deg] a candidate needs to take Q Default: 10.0q_rate_gain::Float64: Q may advance this many times the kite's own arc per step [-] Default: 2.0min_speed::Float64: Minimum angular speed to trust the course estimate [deg/s] Default: 1.0course_tau::Float64: Low-pass on the course estimate [s] Default: 0.5search_window::Float64: Arc half-width of the local Q search [deg] Default: 45.0search_window_max_frac::Float64: Cap on that half-width, fraction of the path [-] Default: 0.125reacquire_dist::Float64: Cross-track error above which the search goes global [deg] Default: 25.0reacquire_margin::Float64: How much better the global best may be before Q jumps [deg] Default: 3.0
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 fromaz_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 Qcourse::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: Whetherprev_azandprev_elhold a position yet
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).
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:
- 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. - 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 beyondreacquire_distfrom the path, so a genuinely off-path kite re-acquires globally, and its half-width is capped atsearch_window_max_fracof the path's arc length, so a small pattern keeps a window rather than losing the guard to a window wider than the curve. - 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 bybranch_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.
SimpleKiteControllers.attractor_index — Function
attractor_index(fec::FigureEightController) -> IntIndex of the attractor point: attractor_distance of arc ahead of the closest point Q of the last calc_attractor call, as calc_attractor walks it.
SimpleKiteControllers.signed_cross_track — Function
signed_cross_track(fec::FigureEightController, az, el) -> Float64Signed cross-track error [deg] of the kite at az, el [deg] to the path at the closest point Q of the last calc_attractor call, right of travel > 0.
SimpleKiteControllers.path_chord_offset — Function
path_chord_offset(fec::FigureEightController) -> Float64Angle [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.
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.
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:
- points whose tangent is more than 90° from it are not candidates at all (a reversed traversal, or the far arm of a lobe);
- among the local minima within
branch_tolof 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.
SimpleKiteControllers.attractor_distance — Function
attractor_distance(fcs::FC_Settings, v_app, l_tether) -> Float64The 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.
SimpleKiteControllers.guidance_rate — Function
guidance_rate(fcs::FC_Settings, v_app, l_tether, v_kite) -> ω_gCorner frequency ω_g = v_kite/(l_tether·D) [rad/s] of the attractor guidance, with D the attractor_distance at v_app [m/s] and l_tether [m] in rad and v_kite the kite's speed [m/s]: the corner of guidance_tf.
Turn-rate law
The identified turn-rate coefficients c1, c2 and their lookup table.
SimpleKiteControllers.V3_TURN_RATE_COEFFS — Constant
V3_TURN_RATE_COEFFSSnapshot of every row of data/turn_rate_coeffs.yaml, as parsed at package load (or by the last reload_turn_rate_table!), keyed by (body_damping, depower). Includes non-passing rows; prefer turn_rate_coeffs, which applies the quality filtering and interpolates between grid points.
SimpleKiteControllers.V3_TURN_RATE_C1 — Constant
V3_TURN_RATE_C1c1 for V3Kite init's default body_damping = [0.0, 0.0, 40.0] at depower 0.25. Prefer turn_rate_coeffs whenever the damping or depower differs.
SimpleKiteControllers.V3_TURN_RATE_C2 — Constant
V3_TURN_RATE_C2c2 for V3Kite init's default body_damping at depower 0.25 — see V3_TURN_RATE_C1.
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 (outcomeother 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,c1is interpolated log-linearly (it decays close to exponentially with depower) andc2/delaylinearly;delayis then rounded up to a multiple of the identificationdt, so an interpolated dead time is never optimistic. Non-passing rows are never used as neighbours.interpolate = falsedisables this and throws instead. - Outside the identified depower range for that damping, or for a
body_dampingwith no rows at all, this throws rather than extrapolating or guessing — re-identify by runningexamples/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.
SimpleKiteControllers.try_turn_rate_coeffs — Function
try_turn_rate_coeffs(fcs; consequence = "flying WITHOUT the feasibility check",
info = true) -> NamedTuple or nothingturn_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.
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.
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.
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.