Run evaluation

Metrics of a logged run

SimpleKiteControllers.fig8_metrics — Function
fig8_metrics(sl; t_start=0.0, settle_time=10.0, settle_d_threshold=5.0, hf_window=0.5)

Compute figure-of-eight quality metrics of syslog sl over the settled window (stats_start to the end). settle_time [s] is a fallback upper bound on how long the entry transient is assumed to take; the actual stats_start is t_start plus however long it takes the cross-track error (var_01 [deg]) to first drop below settle_d_threshold degrees, capped at t_start + settle_time so a run that never converges still gets scored from a bounded window rather than an empty one; the elapsed value is returned as settle_time_used. hf_window [s] is the moving-average width subtracted out to isolate high-frequency content. Returns nothing if the window is empty.

The elevation floor is reported both over the settled window and over the whole run (min_elevation_all) — the latter is the success criterion, because a floor breach during the entry transient still counts as a breach.

Cross-track error is split the same way. rms_d/mean_d cover the reel-out window — the settled samples up to phase 4 — and rms_d is the success criterion; rms_d_all/mean_d_all cover the whole settled window, and reelout_frac is the share the former keeps. Phase 5 holds a stopped winch at depower_final on a path lifted by el_offset_final, a different operating point whose tracking says nothing about the pattern flown for power. max_d stays on the whole settled window: a large excursion is a fault wherever it happens. For a log that never leaves phase 4 the two are identical.

az_amplitude/el_height are the REFERENCE path's own A and B [deg] (azimuth spans ±A, elevation spans B peak to peak), and az_center its centre [rad]. Pass them and the metrics also report how much of the commanded pattern was actually flown: az_reach_pos/az_reach_neg (mean per-lobe azimuth extreme, one value per completed excursion to that side) and the fill fractions az_fill_pos/az_fill_neg/el_fill. Without them the flown extent is still reported, the fractions are NaN, and the lap band falls back to its self-normalized form.

Each of the three may be a single number or one number per log sample, for a run whose reference changes under it — a re-optimized reel-out shrinks its own pattern by a third in azimuth, so one fixed A scores the last laps against a reach nothing ever asked them to fly. Per-sample geometry is used where each quantity is measured: every lobe extreme against the A commanded at the sample it was reached, every excursion's elevation span against the mean B commanded across it, and the lap-detection band against the A in force at each sample. A constant geometry passed per sample is the scalar case exactly.

cross_track, one value per log sample [deg], replaces var_01 as the cross-track error everything above is computed from — the settle detection and rms_d/mean_d/max_d alike. A run whose reference is corrected in the air (simple_opt_reelout.jl lifts the optimizer's curve by el_offset_final and the lobe lift) logs the guidance's own error to var_01, the distance to the path it is steering for; scoring against the optimizer's curve as it arrived, blended the same way but never lifted, says how far the kite flew from what was ASKED for, which is what this argument carries. nothing (the default) reads var_01.

el_fill is measured on the span of each excursion (el_span_lap), not on el_span, which is the span over the whole settled window: the window takes its top from one lap and its bottom from another, so a pattern that descends as the tether grows reads as TALLER than anything that was commanded, and a kite that misses the top of one lobe is covered by the other. el_span is still reported, since the two together say whether the pattern moved. Only the reach distinguishes "tracking the pattern" from "tracking a piece of it" — cross-track error is measured to the closest point of the path and is blind to the size of the eight.

Steering is reported on BOTH sides of the actuator, and the pair is the point: steering_sat_frac/max_steering_used describe the command, tape_rate_frac/max_steering_delivered what the KCU tape did. v_steering [1/s] is the tape's rate limit and must match the kcu: section of the settings YAML in use (0.2 for every V3 file shipped here); it is a keyword so a log from a differently configured KCU can still be scored.

heading_range [deg] is the span of the UNWRAPPED heading over the settled window (max - min, unwrap by wrap2pi'd steps). An oscillating figure-eight stays bounded here regardless of lap count, since each lobe swings back into the same band; it grows without bound the moment the guidance's reference loses its branch and the kite spins an extra loop instead of turning back — invisible to rms_d/max_d, which stay small because the guidance tracks whatever shape it is given correctly, and to the extent/lap checks, which are blind to how many times the heading wound around to fly it.

Why each guard is shaped the way it is: docs/fig8_tuning_log.md.

source
SimpleKiteControllers.print_fig8_metrics — Function
print_fig8_metrics(sl; kwargs...)

fig8_metrics plus a human-readable summary and a pass/fail line against V3Kite.jl's success criteria. Returns the metrics NamedTuple with the verdict merged in (criteria, the number checked, and criteria_failed, the names of those that failed), or nothing, with a @warn, if unavailable.

Pass az_amplitude/el_height (the path's A and B [deg]) to also check the pattern's SIZE: the tracking criteria are all relative to the closest point of the path and are therefore blind to a kite flying a small eight, or one that sits in half the wind window — both are close to the path at every instant. The extra criteria require the mean per-lobe azimuth reach on BOTH sides, and the elevation span, to be at least min_span_frac of what was commanded. They are skipped (and the pass count drops accordingly) when the geometry is not given. They are scored on the fill fractions, so a geometry passed per log sample (see fig8_metrics) is checked lap by lap against the pattern in force at the time; the degrees in the printed criterion names are then the mean of it.

max_heading_range (default 400.0°) bounds heading_range (see fig8_metrics): every clean run measured stays near 330°, so 400° leaves margin above that baseline while still catching even a single mild extra-loop episode (measured +150-450° over baseline) well before it could be mistaken for normal lap-to-lap variation.

Pass require_final = true (default false) to also check that sys_state reaches 5 (phase "final") somewhere in the log — examples/simple_reelout.jl only, whose entry state machine has that phase; a plain simple_fig8.jl run's sys_state never goes past 4 and would fail this check every time, hence the default off. Checks that reel-out actually FINISHED within the run — by either of its two stop criteria, l_set reaching reelout_l_max or n_fig_eight laps completed — not just that the pattern was tracked well.

Pass max_force = f (in N) to also check that the logged tether force never exceeds f — the winch's rating from the winch: section of the settings YAML (max_force, 8400 N for the V3). Checked over the WHOLE run, like the elevation floor: a brief overload during the entry transient still counts. Off by default, since the limit is plant-specific and only known where the settings file is read.

cross_track is passed through to fig8_metrics: one cross-track error per log sample to score instead of var_01.

source
SimpleKiteControllers.reelout_power — Function
reelout_power(sl) -> NamedTuple or `nothing`

Mechanical reel-out power winch_force * v_reelout scored over the window where the tether length setpoint (var_10, examples/simple_reelout.jl only) was still increasing — the run's actual reel-out phase, between the guidance engaging and reelout_l_max. Returns nothing for a log that never reeled out: a plain figure-eight run (var_10 is unused there, so constant), or a reel-out run that never got past the entry.

Fields: mean_power [W] and energy [J] over that window, its duration [s] and sample count n, plus energy_run [J], the same integral over the WHOLE log. The pair is the point — a change that starts reeling earlier trades mean power against a longer window, and only energy_run says which way the run came out. It is the honest total: it also counts what the drum gives back on the reel-in transients outside the window, which energy cannot see.

Also peak_power [W], the window's maximum, and cf_power_ro, its crest factor peak_power / mean_power — how peaky the reel-out power is, relevant for sizing the generator and grid connection. mean_force/peak_force/cf_force_ro [N] are the same triple for the tether force alone, over the same window.

idx is the sample indices making up that window, so a caller can score anything else over exactly the same samples — sl.time[rp.idx] are their timestamps.

Headless, no plotting dependency, so a sweep can call it on every log.

source
SimpleKiteControllers.reelout_ringing — Function
reelout_ringing(sl; detrend_window=2.0, ring_span=20.0, min_peak_gap=1.0,
                peak_floor=0.02, settle_frac=0.05) -> NamedTuple or `nothing`

Characterizes the underdamped ring the winch's v_set = kv*sqrt(force) law excites when reel-out engages (docs/fig8_tuning_log.md, "REEL_OUT winch"): v_reelout overshoots the speed the square-root law settles to and rings for several cycles before decaying into it. Returns nothing for a log that never reeled out, same guard as reelout_power.

The ring rides on the startup ramp (force, and with it v_reelout, is still rising over the same seconds), so peaks are found on the RESIDUAL of v_reelout after subtracting a centered moving average detrend_window [s] wide — the ramp, not the ring, dominates the raw signal's own peak spacing. Only residual maxima above peak_floor [m/s] and at least min_peak_gap [s] apart, within ring_span [s] of reel-out starting, count as ring peaks; this excludes the slower, much smaller speed variation the flown pattern itself imposes once the ring has died out.

Fields: n_peaks; period_s, the mean peak-to-peak spacing; zeta, the damping ratio from the log decrement of successive peak amplitudes; overshoot_m_s, the first peak's amplitude above the local trend; duration_s, the time from reel-out start until the residual last exceeds settle_frac of overshoot_m_s; peak_v_reelout_m_s, the raw (not detrended) speed maximum within ring_span; and steady_v_reelout_m_s, the mean v_reelout after ring_span for comparison. period_s and zeta are NaN when fewer than 2 (respectively no decaying pair of) peaks are found.

source
SimpleKiteControllers.winch_state_pct — Function
winch_state_pct(sl) -> NamedTuple or `nothing`

How the REEL_OUT winch controller spent the reel-out window, as the percentage of its samples in each of WinchControllers.jl's three states (logged to var_12 by examples/simple_reelout.jl): lower_force_pct (state 0, the LowerForceController reeling IN to keep the tether taut), speed_pct (state 1, the v_set = kv*sqrt(force) law itself) and upper_force_pct (state 2, the UpperForceController capping the force at WCSettings.f_high).

Under WCSettings.force_limit = "soft" the law itself is the upper limiter and the UpperForceController is held in reset, so upper_force_pct is then 0 for any log — read the force against f_high directly instead.

Scored over the same window as reelout_power — the samples where the length setpoint was still growing — because var_12 only means anything while the controller is being stepped: before reel-out engages it still reads whatever state the freshly built controller started in. Returns nothing for a log that never reeled out, same guard as reelout_power.

upper_force_pct is a side CONDITION rather than a quality metric: a shape which engages the upper limiter pulls harder than the winch is allowed to hold, so its power was bought against the force cap — the number describes the cap, not the pattern. lower_force_pct is the mirror image and worth watching for the same reason (see the v_ff entry in Plan.md, where it going to zero was the whole power gain).

source
SimpleKiteControllers.lap_durations — Function
lap_durations(sl) -> (; t_start::Vector{Float64}, dt::Vector{Float64})

Sim time each FULL figure of eight took, from the logged fig_8 lap counter: lap k runs from the first sample at which fig_8 REACHES k to the first at which it reaches k + 1, so the lap still in progress when the run ends is left out. First arrival, not every upward step: in logs flown before the counter was made monotone it can dip back for a step or two at a lap boundary (a path install re-indexing the kite), and counting the second crossing as a lap start gave a 0.0 s "fastest lap". fig_8 counts full traversals of the reference path in the air (phases 4 and 5 alike), so a lap here is one whole pattern regardless of the path's shape.

source
SimpleKiteControllers.weighted_prediction — Function
weighted_prediction(pred_timeline, t_samples) -> (; shares, power)

The optimizer's predicted mean power for a run that flew several paths, weighted by how much of the scored window each was in the air for. pred_timeline holds one (; t, power) per installed path, ascending in t (the install time [s]); t_samples are the times of the window's samples, e.g. the reeling window's of reelout_power. shares lists (; from_s, power, share) for every path flown during the window, power [W] is sum(power * share).

Scoring a window against the FIRST path's number alone compares the measurement with a path that was not in the air for some of it.

source

Helpers

SimpleKiteControllers.on_log — Function
on_log(t_log, t_src, v_src) -> Vector{Float64}

Per-log-sample view of a per-step series, taking the last value recorded at or before each log timestamp (the first value for a sample before the series starts). Both time vectors must be ascending. Used to hand fig8_metrics the geometry that was COMMANDED at each sample rather than one number for the run, without depending on the logger writing exactly one row per step.

source
SimpleKiteControllers.unwrap_angle — Function
unwrap_angle(a) -> Vector

The angle series a [rad] unwrapped: each step wrapped to ±π and summed from first(a), so a plot or a DFT does not see the ±π jumps.

source
SimpleKiteControllers.unwrap_onto — Function
unwrap_onto(ref_u, ref_w, a) -> Vector

The angles a [rad] put on the unwrapped branch of a reference: ref_u is the reference unwrapped (unwrap_angle), ref_w the same reference wrapped, so a is drawn next to ref_u instead of jumping by 2π.

source

Commented run summary

The YAML file written next to the log of a run, and the archive of its input files.

SimpleKiteControllers.write_yaml_commented — Function
write_yaml_commented(io, indent, node; comment_col = 36, color = false)

Serialize a nested dictionary (an OrderedDict keeps the key order) as YAML, recursing into AbstractDict values and appending a trailing # comment for leaves given as (value, comment) pairs, aligned to comment_col where the line is short enough. YAML.write_file has no concept of comments, hence this by hand. color = true adds ANSI syntax highlighting (keys, string/number values, comments); leave it off when io is a file, so no escape codes end up on disk.

source
SimpleKiteControllers.time_keyed — Function
time_keyed(row, rows) -> Vector{Pair{String, Any}}

Summary entries keyed by time, t_<time>_s (%05.1f), where row(e) gives (time, value) for each element e of rows; pass the result to an OrderedDict. Two rows CAN share a time — a blocking solve is requested and collected on the same step, so a seed skipped from the failure cache carries the same t as the install that follows it — and a plain comprehension silently keeps the last of them (which hid the cache's own skips the first time it ran), so a repeated key gets a suffix _2, _3, ...

source
SimpleKiteControllers.package_git_state — Function
package_git_state() -> (; hash, status)

The commit this package's code is at (git rev-parse --short HEAD) and whether its working tree is "clean" or "dirty"; both "unknown" without git or outside a checkout. The package repo, not the caller's: a run summary reports the controller code that flew.

source
SimpleKiteControllers.success_verdict — Function
success_verdict(fig8m) -> (verdict, comment)

The pass/fail verdict of print_fig8_metrics's result as the console logs it, "all N passed" or "FAILED: " and the criteria that broke, as a summary leaf. fig8m === nothing (no settled samples) is "not scored".

source
SimpleKiteControllers.simulation_block — Function
simulation_block(script, project, turbulence, wind_speed, run_time) -> OrderedDict

The summary's simulation section: the script and system project flown, the turbulence level and mean wind speed passed to init, when (run_time, a DateTime) and where the run finished, and package_git_state.

source
SimpleKiteControllers.fig8_metrics_block — Function
fig8_metrics_block(fig8m, laps_flown; cross_track_ref = nothing,
                   nested_verdict = false) -> OrderedDict

The summary's fig8_metrics section: the numbers the verdict of print_fig8_metrics was computed from, and the lap times of lap_durations (laps_flown). cross_track_ref names what the cross-track error is measured against, for the comments. nested_verdict = true repeats success_verdict as the section's last key.

source
SimpleKiteControllers.reelout_block — Function
reelout_block(sl, fcs, l_tether; stop_reason, laps_reeled, window_means = false)
    -> (; block, rp, p4)

The summary's reelout section of the log sl, printed as it is built: the apparent wind over phase 4 (against fcs.course.v_app_ref), the tether's reel-out from l_tether and why it stopped (stop_reason, "" when reel-out never stopped; laps_reeled, the laps completed by then), and the force, power, winch states and ringing over the reeling window. window_means = true adds the window's mean and peak force and power.

Also returns rp (reelout_power, nothing when the tether never reeled out) and p4, the phase-4 power, force, reel-out speed and depower as (; power, force, v_ro, depower_av), each of the first three (; av, min, max), the minima without the last 2 s of phase 4; nothing when phase 4 was never reached.

source
SimpleKiteControllers.performance_block — Function
performance_block(t_sim, t_wall, dt, vsm_interval; blocked_s = nothing,
                  extra = Pair{String, Any}[]) -> OrderedDict | nothing

Speed of the SIMULATED time t_sim [s] against the wall clock t_wall [s] of the simulation loop (> 1 is faster than realtime), printed and as the summary's performance section; nothing, with a warning, when no simulated time elapsed. dt is the timestep, vsm_interval the VSM update interval in steps. With blocked_s, the wall time the loop was frozen waiting for an optimizer, the rates exclude it and the printed line says how much it was. extra are further entries, placed after wall_time.

source
SimpleKiteControllers.opt_cycle_max — Function
opt_cycle_max(reopt_cycles, startup_solve_s) -> (; s, comment)

The longest a new figure of eight took to compute, retries included: the startup solve of startup_solve_s [s] (which already contains its own retry seed) against every re-optimization cycle in reopt_cycles, each (; t, l, status, wall_s) from its first request to the verdict. comment says which one it was.

source
SimpleKiteControllers.run_input_files — Function
run_input_files(project, project_set) -> Vector{String}

The input files every reel-out run is configured by: the system project, the plant/solver settings it names, the winch gains, the flight-controller tuning and gui.yaml (the project/sim_time/turbulence choice). A caller with more inputs appends them before handing the list to archive_run_files.

source
SimpleKiteControllers.archive_run_files — Function
archive_run_files(output_path, run_time, input_files, output_files) -> archive_dir

Copy a run's input_files and output_files into one timestamped folder, <output_path>/archives/yyyy-mm-dd_HHMMSS of run_time, so the exact config that produced a log survives even after the next run overwrites output/*. The inputs are copied back to output_path too, for the plotting script to find them: they get overwritten on the next run, but that is the point — each run's plots use the settings that run actually flew. Files that do not exist are skipped.

source