Iterated extended Kalman filter for wind and state estimation of tethered flying systems (airborne wind energy), in native Julia.
This is a port of the Python package EKF-AWE
(awes_ekf), the filter described in
O. Cayon, S. Watson, R. Schmehl: Kite as a sensor: wind and state estimation in tethered flying systems, Wind Energ. Sci. 10, 2161–2188, 2025, https://doi.org/10.5194/wes-10-2161-2025
It contains only what is needed to run and use the filter: no plotting and no postprocessing. Given the same configuration and flight data, it reproduces the Python results (see Validation).
Reference implementation. Ported from, and validated against, EKF-AWE commit
f1b95a0
(f1b95a08c913b1d361fde51583d305f0de03fce4, 2026-10-19, "fix(tether): pair KCU
drag coefficients with their areas; onboard turbine drag"). Changes to the
Python filter after that commit are not in this port.
- State: kite position and velocity, wind velocity, aerodynamic coefficients (CL, CD, CS), tether length and the elevation/azimuth of the first tether element. Optionally it also holds sensor offsets and identified constants: depower and the steering-dependent aero terms.
- Dynamics: the kite is a point mass. The tether force at the kite comes from a quasi-static lumped-mass tether, with the KCU as a separate point mass at the end of a bridle segment.
- Jacobians (paper Sect. 4.3, Fig. 6): the tether model inside f and h is
differentiated only with respect to tether length, elevation and azimuth. The
kite kinematics and wind it uses are taken from the nominal state x*, which is
held fixed. In code,
dynamics(m, x, u, x0)andobservation(m, x, u, x0)read the tether states fromxand the rest fromx0, and ForwardDiff differentiates with respect toxonly. - Propagation: CVODES (Sundials.jl), BDF with a dense linear solver and the exact ForwardDiff Jacobian. Tolerances are abstol 1e-8 and reltol 1e-6, the defaults of the CasADi integrator in the Python version.
- Update: an iterated EKF with the Joseph-form covariance update.
- Sensor calibration: an optional pre-run without the pitot tube (or the angle-of-attack vane), then a dynamic-pressure calibration (or offset) fitted against the filter estimate.
Requires Julia ≥ 1.12. The package is not in the General registry; install it from GitHub:
julia> ] add https://github.com/ocayon/AwesEKF.jlor, for development and to run the examples, clone the repository and run
bin/install:
git clone https://github.com/ocayon/AwesEKF.jl
cd AwesEKF.jl
bin/installbin/install installs the tested package versions of the default manifest for
your Julia version (1.12 or 1.13) and precompiles them. Run it again after every
git pull or branch switch. New to Julia? bin/install -i asks a few questions
and can also install a supported Julia version (needs
juliaup) and add the alias jl for
bin/run_julia.
For development it is recommended to install
Revise in your global environment, so
that code changes take effect without restarting Julia. bin/run_julia loads it
automatically if it is available:
julia -e 'using Pkg; Pkg.add("Revise")'The repository includes a 10-minute example from the public LEI V3 flight of
8 October 2019. It contains the flight data, the as-flown system and the tuned
filter configuration. After bin/install, start the Julia REPL with
bin/run_julia and include the example:
bin/run_juliajulia> include("examples/run_v3_example.jl")This takes about 30 s, including compilation. It prints the estimated wind
speed, wind direction and aerodynamic coefficients per minute, and writes the
full results to results/v3_example.h5. Julia and Python agree on this example
to within 2e-5 of each signal's standard deviation (validation/compare_runs.jl).
| Path | Contents |
|---|---|
examples/data/ |
The flight data slice, its source and licence (CC BY 4.0) |
examples/LEI-V3-KITE/ |
Filter configuration and system description |
Other flights. The filter needs a processed flight CSV in the EKF-AWE input
format (the columns listed in examples/data/README.md) and a kite folder with
ekf_config.yaml and a system*.yaml. Raw logs are converted with the
preprocessing scripts of EKF-AWE
(data/data_preprocessors/); its data/ folder holds the kite configurations.
The configuration follows the EKF-AWE/AWETrim convention: a kite folder holding
ekf_config.yaml (filter settings and tuning) and one or more awesIO
system*.yaml files (the physical system).
using AwesEKF
result, flight_data, cfg = run_flight(
"path/to/processed_flight.csv", # EKF-AWE input CSV spec
"path/to/LEI-V3-KITE"; # kite config folder
system_yaml = "system_flown_2019.yaml",
start_minute = 60, end_minute = 100, # optional slice
out = "results/run.h5", # optional results file
)
result.outputs["wind_speed_horizontal"] # one value per kept step
result.x # state history (steps x states)From the command line:
julia --project scripts/run_flight.jl --csv <file> --config <kite folder> \
--system-yaml system_flown_2019.yaml --start 60 --end 100 --out run.h5
The results file uses the EKF-AWE HDF5 layout (ekf_output, flight_data,
config_data). It adds state (state history, covariance diagonal) and meta
(initial state, kept and failed samples, IEKF iterations, calibrations).
| File | Contents |
|---|---|
src/config.jl |
Config loading (ekf_config.yaml + awesIO system yaml), KCU and tether parameters, noise settings |
src/layout.jl |
State, input and measurement vector layouts (same order as the Python code) |
src/tether.jl |
Quasi-static tether model with KCU and bridle; derived frames and coefficients |
src/model.jl |
Process model f(x, u, x0) and observation model h(x, u, x0) |
src/propagation.jl |
One-step state propagation with CVODES |
src/filter.jl |
(Iterated) EKF: covariance prediction, measurement update, step! |
src/input.jl |
Flight CSV reading, minute slicing, per-sample filter inputs |
src/initial_state.jl |
Tether-shape solve for the initial state |
src/calibration.jl |
Sensor calibration pre-run (pitot, angle of attack) |
src/output.jl |
Output row per step (wind, aero coefficients, orientations, ...) |
src/run.jl |
run_filter (with re-initialisation on failure), run_flight |
src/save.jl |
HDF5 results file |
validation/ compares the port with the Python implementation. Flight data and
results stay local; they are never committed.
python_reference.pyruns the Python pipeline non-interactively and stores states, outputs and run metadata (run it with the EKF-AWE environment).python_model_probe.py+compare_model.jlcompare f, ∂f/∂x, h, ∂h/∂x, one propagation step and the output row at given states.compare_runs.jlcompares two results files sample by sample.
Validated against EKF-AWE f1b95a0 on two LEI V3 flights, with the
configuration in EKF-AWE's data/LEI-V3-KITE: all steering-dependent stages,
dynamic depower, pitot pre-run calibration and the zero-vertical-wind
pseudo-measurement on.
Model functions (at the initial state and at perturbed states of 3 samples): f, h and both Jacobians agree to ≤ 1e-15 relative and the output rows to 1e-9. One propagation step agrees to ~1e-7, the integrator tolerance. The initial state agrees to 4e-16.
Full runs, Julia against Python. "Independent" means each implementation computes its own initial state and pitot calibration. "Lockstep" means Julia takes both from the Python run.
| 2019-10-08, min 60–100 | 2025-10-09, min 0–28 | |
|---|---|---|
| Filter steps | 24 000 | 16 799 |
| Failed steps (re-initialisations) | 0 / 0 | 1 / 1, same sample |
| IEKF iterations identical | 100 % of steps | 100 % of steps |
| Pitot calibration k (Julia / Python) | 0.680554 / 0.680540 | 0.804635 / 0.804635 |
| Worst channel, RMS difference / signal std, independent | 9.2e-4 | 1.3e-4 |
| Worst channel, RMS difference / signal std, lockstep | 4.2e-5 | 1.3e-4 |
| Horizontal wind speed, max abs difference | 0.45 mm/s | 0.21 mm/s |
| Wind direction, max abs difference | 8.5e-5 rad | 1.2e-4 rad |
| Lift coefficient, max abs difference | 8.4e-5 | 1.7e-5 |
| Drag coefficient, max abs difference | 1.2e-5 | 1.9e-5 |
| Runtime, pre-run + main loop, Python | 168 + 139 s | 133 + 184 s |
| Runtime, pre-run + main loop, Julia | 14 + 18 s | 18 + 15 s |
| Speed-up | 9.6× | 9.6× |
Timings are for one process on the same Windows 11 laptop, with Julia 1.13.2 against Python 3.13 / CasADi 3.7.0. The Julia pre-run time includes JIT compilation.
In the independent 2019 run, the larger differences come from the pre-run: its pitot coefficient differs by 2e-5 relative, which then scales the measured apparent wind speed of the whole run. With the calibration shared (lockstep), the remaining differences are integrator-level.
Reproduce (Python with the EKF-AWE environment, files go to validation/out/):
python validation/python_reference.py --csv <flight.csv> --config-folder <EKF-AWE>/data/LEI-V3-KITE \
--system-yaml system_flown_2019.yaml --start-min 60 --end-min 100 --out validation/out/py.h5
julia --project scripts/run_flight.jl --csv <flight.csv> --config <EKF-AWE>/data/LEI-V3-KITE \
--system-yaml system_flown_2019.yaml --start 60 --end 100 --out validation/out/jl.h5
julia --project validation/compare_runs.jl validation/out/jl.h5 validation/out/py.h5
Add --x0-from validation/out/py.h5 --pitot-from validation/out/py.h5 to the
Julia run for lockstep.
These are reproduced on purpose so both implementations give the same results. Fix them in both codes together:
-
Output columns for states that are not in the configuration (
tether_elevation_offset/tether_azimuth_offsetwithout angle measurements,k_cl_up/k_cd_upwithout dynamic depower,kite_thrust_force) repeat the first state, the kite x position. -
When a step fails, the filter is re-initialised from a fresh tether-shape solve with the default wind guess
[0.001, 8, 0], not the configured one. -
The tether-shape solve for the (re-)initial state is a port of scipy's
least_squaresas the Python code calls it: TRF without bounds, a 2-point finite-difference Jacobian, and xtol = ftol = 1e-5. At low tether force the closure has no exact solution (the modelled tether sags to the ground). The solver then stops in an algorithm-dependent local minimum, which can be tens of metres off, and the filter restarts from it. A different solver would restart elsewhere. -
In the calibration pre-run, a failed integration leaves the state and the input unchanged. The next sample therefore retries the identical integration, so once the pre-run fails it keeps failing for the rest of the window. (2019-10-08 from minute 0 triggers this in both implementations.)
Plotting, postprocessing (IMU offset removal, flight-phase labels, the
turn-rate law), the data preprocessors, and the model_yaw option (flagged
"don't use" in the Python code).
If you use this code, please cite the paper:
Cayon, O., Watson, S., and Schmehl, R.: Kite as a sensor: wind and state estimation in tethered flying systems, Wind Energ. Sci., 10, 2161–2188, https://doi.org/10.5194/wes-10-2161-2025, 2025.
@article{cayon2025kite,
author = {Cayon, Oriol and Watson, Simon and Schmehl, Roland},
title = {Kite as a sensor: wind and state estimation in tethered flying systems},
journal = {Wind Energy Science},
volume = {10},
pages = {2161--2188},
year = {2025},
doi = {10.5194/wes-10-2161-2025}
}The original Python implementation is archived at https://doi.org/10.5281/zenodo.15269632 (EKF-AWE).
This work was supported by the European Union's Horizon Europe programme (Climate, Energy and Mobility) through the MERIDIONAL project, grant agreement No. 101084216.
This work is licensed under the MIT open source licence. See the LICENSE file for details.
Technische Universiteit Delft hereby disclaims all copyright interest in the program "AwesEKF.jl" (an iterated extended Kalman filter for wind and state estimation of airborne wind energy systems, ported to Julia from EKF-AWE) written by the Author(s).
Prof. Dr. H.G.C. (Henri) Werij, Dean of the Faculty of Aerospace Engineering
Copyright © 2024-2026 Oriol Cayon
AwesEKF.jl is a derivative of EKF-AWE (Copyright © 2024 Oriol Cayon, MIT License, for which TU Delft likewise disclaims copyright interest).