This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Julia package with simple flight controllers for airborne wind energy kites: a parking controller (nose into the wind, kite held airborne in steady state) and a figure-of-eight path-following controller (L0 attractor guidance after Fernandes et al., Energies 2022).
The defining constraint: src/ never depends on a kite model. It is pure geometry,
settings and metrics — no simulation, no plant. Everything that touches a kite
(init/step!, both winch modes, span_mean_aoa, create_heading_pid) lives in
examples/ and comes from V3Kite.jl
through its public API. Keep it that way: a new helper that needs a V3KITE belongs in
an example, not in src/.
julia --project -e 'using Pkg; Pkg.test()' # full suite, ~7 s (pure geometry)In a live REPL, include("test/runtests.jl") for everything, or
include("test/test_fig8_controller.jl") for the figure-eight / metrics / turn-rate
testsets alone (it activates nothing, so using SimpleKiteControllers must already work
in that environment). bin/run_julia starts a REPL on the project, picking up a system
image at bin/kps-image-<version>-<branch>.so if one exists.
The example is run by include, not from the shell — it activates examples/ itself:
include("examples/simple_fig8.jl") # cached: ~2x realtime, 150 s sim = ~75 s wallA script's caller inputs are keywords, not globals (src/script_inputs.jl):
run_example("simple_fig8.jl"; show_plots = false) suppresses the figures at the end.
They hold for that one run — a plain include runs with the defaults, so a sweep
passes them per run and a leftover value never silently changes a later run. A
keyword the script does not take is an error. fcs is NOT
sticky: every include reloads it fresh from fc_settings.yaml via
fcs = FC_Settings(fc_settings(project)), unconditionally and with no @isdefined
guard, so a pre-defined or hand-mutated fcs left in the REPL is discarded the moment
the run that was meant to use it starts — override a parameter by editing the YAML
file instead. Keep the same REPL across runs — the
first one compiles the V3 model (a few minutes, ~23 MB) into V3Kite's own cache
directory and settles the wing into examples/cache/settled_*.bin. Both are reused
afterwards, and that is what the ~2x realtime above assumes: with the packages
precompiled and both caches present the run is fast, the very first one is minutes
slower. A body_damping the settling cache has not seen pays that cost again.
Kaimon exposes the live Julia REPL and code-intelligence tools; prefer it over a shell
subprocess or ad-hoc grep/find.
- Executing code —
exevaluates in the REPL the user is watching;src/edits are picked up automatically via Revise, so never callRevise.revise()yourself. Run tests byinclude-ing the test file into that REPL, notPkg.test()in a subprocess — a fresh subprocess can't reuse the compiled V3 model orexamples/cache/settled_*.bin, so a full run there pays the multi-minute first-run cost every time. - Finding code — semantic search when exploring or when you only know what the code does, not its name; grep when you already have an exact symbol, call site, or string to match.
A reel-out run takes ~4-6 min of wall time (150 s of sim at ~1.4x realtime, plus the
~100 s the blocking re-optimizer holds it), and the include returns nothing until it
is over. Do not wait on it with a guessed sleep: examples/simple_opt_reelout.jl
removes output/last_run_done.txt at startup and writes it as the very last thing it
does, so its presence means "this run is over" and never "a previous run was". Block
on the file instead:
until [ -f output/last_run_done.txt ]; do sleep 5; done; cat output/last_run_done.txtIt carries the timestamp, the archive directory, the summary path, the pass/fail verdict
and the measured power — enough to know whether to bother reading further. Read the
run's numbers from the archive it names, not from output/: every run overwrites
output/reelout_150m_opt.{arrow,yaml}, and a run started while you are reading them
will swap them under you.
output/scenarios/ is a symlink into the
SimulationResults repo, which
keeps the latest 150 m reel-out optimizer run for each wind speed: v03, v06, v08,
v09, v10 — the number is the mean wind speed in m/s, not a version counter.
Measured mean reel-out power ranges from 394 W at 3 m/s to 35 kW at 10 m/s.
Each folder is a self-contained record of one run: the flight log
(reelout_150m_opt.arrow), the run summary (reelout_150m_opt.yaml — git hash and
dirty status, wind and turbulence, wall-clock time, fig8_metrics, and measured vs.
predicted power; read this first instead of loading the Arrow), and copies of every
settings file that fed it (fc_settings_reelout.yaml, settings_reelout_150m.yaml,
system_reelout_150m.yaml, traj_opt.yaml, wc_settings.yaml, gui.yaml), so the run
is reproducible independently of whatever data/ currently holds. Nothing here is
overwritten by a new run, so this is the place to compare wind speeds or an older
tuning — output/ itself is not.
A failing solve runs to IPOPT's iteration cap (~81 s, against ~1.5 s for one that
converges) and in reopt_blocking mode the simulation is frozen for all of it, so
src/awetrim_client.jl records every failed request in
output/opt_failure_cache.yaml and does not send it again. Worth ~88 s a run on the
150 -> 380 m reel-out, whose first guess seed fails at two lengths and is silently
recovered by reopt_retry_el_offset.
clear_opt_failures() # forget them all; or delete the YAML, or drop one entrySet opt_failure_cache: false in data/traj_opt.yaml to bypass it — after an
AWETrim or server upgrade, when what used to fail may not any more. The key covers
everything the server sees and the tether length is EXACT, because the failures are
isolated pockets in length (180.0 m converges, 180.00027 m does not), so a request
only hits the cache when a run repeats it exactly.
simple_opt_reelout.jl sends every request through an OptChain
(src/awetrim_client.jl). It stores each APPLIED result (an installed path) in
output/opt_chain_cache/, one JSON file per key, so a rerun that sends the same
requests replays those results without solving. A warm /step depends on the state
the server holds, so the key is the whole request chain since the /init. The
replies a warm start built on are stored along with the applied result, rejected
ones included. Failed WARM steps are stored there as well (under
opt_failure_cache), since the YAML failure cache above only keys cold requests.
After a hit the server no longer holds the chain's state, so the next miss first
rebuilds it (/init seeded with the cached optimum, then one /step). The rebuilt
state gets its own key, and results solved from it are stored under that lineage.
clear_opt_chain_cache() # forget them all; or delete the directoryThe key cannot see the server: clear the cache, or set opt_success_cache: false
in data/traj_opt.yaml, after any AWETrim change. Otherwise a run replays the old
optimizer's answers.
Only simple_opt_reelout.jl writes the marker; copy its two lines (the rm next to
mkpath(output_path) in the script itself and the open(RUN_DONE_FILE, "w") block at
the end of examples/reelout_results.jl) into another example that needs to be waited
on.
init is called without cache_path on purpose. V3Kite's default puts the model
where its @compile_workload compiled against it — always a depot scratchspace now,
for a local checkout as much as an installed copy — and deserializing any other model
binary costs ~40 s of re-JIT per process, because the RuntimeGeneratedFunction id is part
of the type the cached SciML specializations are keyed to.
Root Project.toml declares [workspace] projects = ["examples"], so examples/ keeps
its own Project.toml (GLMakie, V3Kite and its solver chain — deliberately not deps of
the library) but resolves against the single Manifest.toml at the root. A dependency
shared by both is therefore the same version in a script as in the package it exercises.
Needs Pkg 1.11+; older Pkg silently resolves examples/ standalone.
Both Project.toml files carry comment blocks explaining why each compat bound and
[sources] pin exists (a RuntimeGeneratedFunctions exact pin that keeps the model cache
loadable, the V3Kite source). Read them before touching a version bound.
examples/Project.toml can source V3Kite from a git rev or from a local path; a
path entry is machine-specific and not committable. Both give init() in ~2.9 s. The
choice is about Revise on V3Kite's source, not about speed — that was true until
2026-08-05, when init cost ~34 s under url/rev, and the real cause turned out to be
that the run deserialized a model binary V3Kite's precompile workload had never compiled
against. Dropping cache_path fixed it for both sources (a PackageCompiler sysimage is
not the lever — it is worth ~4 s either way).
Switch it with bin/dev (local checkout, default ../V3Kite) and bin/free (back to
url/rev); never edit it by hand. bin/dev comments the url/rev line out and adds
a path line beside it, so bin/free restores the pinned rev verbatim — pass
bin/free <rev> only to deliberately move the pin. bin/dev --status reports which
source is active without changing anything. A path entry is machine-specific, so run
bin/free before pushing. Either direction ends in Pkg.resolve() + Pkg.instantiate(),
which triggers a ~3 min V3Kite precompile.
LocalPreferences.toml (root and examples/) sets [Revise] revise_structs = true, so a
live REPL session picks up edits to struct definitions (FC_Settings,
FigureEightSettings, ...) without a restart. Julia 1.12+ only.
src/SimpleKiteControllers.jl sets the include order — turn_rate_table.jl before
figure_eight_controller.jl, since the guidance's feasibility helpers default c1 to
V3_TURN_RATE_C1 defined there.
figure_eight_controller.jl—FigureEightSettings/FigureEightController. Builds a discretized lemniscate in (azimuth, elevation) degrees, finds the closest point Q and returns an attractor point a fixed arc ahead (calc_attractor), then the great-circle course to it (navigate_fig8). Also the curvature feasibility check (min_turn_radius,path_min_radius,path_radius_profile,check_pattern_feasible).turn_rate_table.jl—turn_rate_coeffs(body_damping, depower), interpolatingc1(log-linearly),c2and the steeringdelayfromdata/turn_rate_coeffs.yaml. Owns the loaded table as mutable state (reload_turn_rate_table!). Non-passing rows are never interpolation neighbours, and a lookup outside the identified range throws rather than extrapolating.fc_settings.jl—FC_Settings, every tuning parameter of a figure-eight run, loaded fromdata/fc_settings.yaml. Pluswinch_force_gains, which applies thecompliancescaling and returns plain numbers (the winch object it feeds belongs to the kite model), andproject_file, which resolves the system project against this package'sdata/.FC_Settingsholds the controller's tuning ONLY: the plant, the run length (sim_time) and the timestep (1/sample_freq) are indata/settings_fig8_200m.yaml, and the example takess.steps/s.dtback from the model afterinit.fig8_metrics.jl—fig8_metrics/print_fig8_metrics, headless scoring of a flown log. No plotting dependency, so sweeps can run headless.course_controller.jl—CourseControllerSettings/CourseController, the figure-of-eight inner loop: commanded course in,rel_steering/rel_depowerout (calc_steering). Owns the four-phase entry state machine (0 park, 1 dive, 2 hold, 3 transition, 4 fig8; 5 "final" is winch-triggered from outside viaset_phase!), the entry descent limiter, and the heading/course feedback blend.winch_table.jl—winch_f_low(v_wind)andwinch_force_limit(v_wind), wind-speed-dependent parameters of WinchControllers.jl's reel-out law, looked up in the system project'swinch_tablefile.kvitself is flat, fromdata/wc_settings.yaml.traj_opt_settings.jl—TrajOptSettings, the settings of a run flown along an externally optimized path (examples/simple_opt_fig8.jl), loaded fromdata/traj_opt.yaml.
examples/simple_fig8.jl is the integration: it drives CourseController from
SysState, plus the winch mode choice and the simulation loop.
- Two data paths.
skc_data_path()is this package'sdata/, always;KiteUtils.get_data_path()is whatever was last set.examples/simple_fig8.jlpoints it at this package'sdata/and it STAYS there for the whole run — the settings andwc_settings.yamlare here, while the arrow log goes tooutput/(named after the project'slog_filesetting) andsimple_fig8_plots.jlloads it from there too. That needs a V3Kite whoseinitdoes not move the global path (merged forv1.0.1); against an older V3Kite the path flips tov3_data_path()atinitand the log lands in the model'sdata/instead. The run's system project does not depend on either:project_file()returnsdata/system_fig8_200m.yamlas an ABSOLUTE path, which makes KiteUtils resolvesim_settingsnext to it —data/settings_fig8_200m.yamlhere, not the identically named file in the model's data. A project this package does not carry is passed through unchanged. The geometry, polars and VSM settings never go through the project file at all; V3Kite loads them fromv3_data_path()directly. - One state file,
data/gui.yaml(gitignored;gui.yaml.defaultis the template). Project,sim_time, plots anddefault_turbulenceall live in itsgui:section.src/gui_state.jlreads it with a line regex and writes it with KiteUtils'update_yaml_scalar, and V3Kite'sset_default_turbulence(; data_path = skc_data_path())writes the turbulence key into the same file — line-based on both sides, so the keys and the comments survive each other.simple_fig8.jlpasses the level toinitasuse_turbulence: the model's owndata/gui.yamlis never consulted, and is read-only for a Pkg-installed V3Kite anyway. - Bearing convention:
0= towards zenith, positive towards larger azimuth.chi_setis directly comparable toSysState.heading.SysState.courseis not — it needswrap_to_pi(course + pi); feeding it raw is positive feedback and diverges the run. rel_steeringis fed unnegated on this plant (measured, r = +0.998). Other kites in the ecosystem negate; that does not transfer.- The pattern must be flown low and wide. The V3's minimum angular turn radius is
1/(L·c1·u_s), and a lemniscate's tightest curvature is on the lobe's upper shoulder, collapsing as the pattern is raised (cos(elevation)compresses the azimuth axis). A figure-eight near zenith is geometrically impossible at any PID tuning —check_pattern_feasibleprints the margin at startup. - Log slot mapping (
var_01…var_09,bearing,sys_stateasCourseController's phase 0–4) is documented inexamples/simple_fig8.jl's docstring and relied on byfig8_metrics.jlandsimple_fig8_plots.jl. Changing a slot means changing all three.
- SPDX
MPL-2.0header on new source files (the two older parking-controller files predate this). - Three places for three kinds of writing, and they must not swap roles: a docstring
says what a thing is and how it works; an inline comment states a single non-obvious
fact;
docs/fig8_tuning_log.mdcarries the story — what was measured, what was tried, which levers are closed. A measurement or a failed experiment in a docstring belongs in the log instead. See "Coding conventions" below for the hard rules. - Tunables go into a YAML file loaded through a
@with_kwstruct (FC_Settings,FigureEightSettings), never hardcoded in a script. Unknown YAML keys are an error; missing ones fall back to the struct default. The YAML header comment block acts as the file's docstring. - Never hardcode
c1/c2/delay— alwaysturn_rate_coeffs(fcs.body_damping, depower). Both arguments move them a lot.body_dampingis the key: V3Kite'sinitdecays it to a floor of 0.8x it, so the one value fixes both the settling transient and the damping the pattern is flown with. - Terminology: "reel-out", never "pay-out". The tether is reeled out, the winch reels out, the phase is the reel-out window — in code, comments, docstrings, YAML, docs and diagrams alike. "pay-out"/"pays out"/"paying out" were renamed out of the repo on 2026-08-16; do not reintroduce them.
- The dated tuning history (sweeps, reverted attempts, why each lever is closed) lives
in docs/fig8_tuning_log.md. Add findings there, not in the
settings docstrings. Its older entries predate the migration: ALL-CAPS names are today's
FC_Settingsfields, and the plans anddata/files they cite are V3Kite's (its own header block maps this out). The sections at the end were moved out of source comments on 2026-08-03 and are pointed at from the code they came from.
- Branch
main. V3Kite is atv1.0.2, but sourced from a local checkout — see "Sourcing V3Kite" above;examples/Project.tomlmust be put back onurl/revbefore pushing.
- Inline comments are ONLY allowed when stating a very non-obvious fact, and then keep them to 1 line at most. Give every type/function a docstring (""" not #) instead, but not too verbose, people won't reed it if your docstring is too long, and explain how the code works in the docstring, but not the whole story behind it.
- Remove or make inline comments 1 line where you see them.
- In YAML files, consider the comments at the top of the file as docstring where you can add multiline comments.
- Everything with a docstring should be added to the docs, otherwise you get an
error when building the docs. (This repository has no Documenter setup yet — no
docs/make.jl, nodocs/Project.toml— so nothing enforces this today.)