Engee documentation
Notebook

The UAV Swarm model

This example considers an integrated agent-based swarm control model for unmanned aerial vehicles (UAVs) to perform the task of searching for and destroying mobile targets at a limited range. The code encapsulates key aspects of swarm behavior: decentralized management, joint mapping of the situation, communication between agents within a limited range, and cooperative decision-making to attack targets that require the simultaneous use of multiple drones to neutralize.

The following libraries and tools are used in this project:

using Random — provides functions for generating pseudorandom numbers, which is critical for modeling stochastic behavior of drones and targets.

using LinearAlgebra — offers tools for linear algebraic operations, such as calculating the norm of a vector (norm), used to calculate the distances between agents.

using Statistics — contains basic statistical functions, including calculating the average (mean) to analyze the simulation results.

gr() — activates the GR backend for the Plots.jl library, which provides high-speed and high-quality visualization of the swarm animation.

In [ ]:
using Random
using LinearAlgebra
using Statistics
gr()
Out[0]:
Plots.GRBackend()

Now let's look at the structures of the objects used in the project.

Structure: SimulationConfig

Purpose: Storage of all simulation configuration parameters to ensure flexibility and ease of experiment management.

  1. world_size::Tuple{Int, Int} — The dimensions of the virtual polygon (width and height) in conventional units, limiting the area of movement of agents.
  2. n_drones::Int — The total number of drones in the swarm.
  3. n_targets::Int — The total number of targets on the range.
  4. dt::Float64 is the value of the time step (time delta) for integrating the equations of motion, which determines the discreteness of the simulation.
  5. total_time::Int is the total duration of the simulation, measured in the number of time steps (frames).
  6. comm_range::Float64 — The maximum range at which two drones can exchange data (communication radius).
  7. sensor_range::Float64 — The maximum range of target detection by drone sensors (viewing radius).
  8. drone_speed::Float64 — Basic drone movement speed in search mode.
  9. target_speed::Float64 — The basic target movement speed.
  10. seed::Int is a seed for a pseudorandom number generator that ensures reproducibility of experimental results.

Structure: Drone

Purpose: Description of the state and behavior of one drone agent in a swarm. It is mutable because its fields are actively updated during simulation.

  1. id::Int Is the unique identifier of the drone.
  2. pos::Vector{Float64} — Vector with the current coordinates of the drone on the plane [x, y].
  3. vel::Vector{Float64} — Vector with current drone speed components [vx, vy].
  4. state::Symbol — Current status of the drone (:searching - search, :diving - attack), which determines his behavior.
  5. map::Matrix{Float64} is a local map of known targets, which is a matrix where 1.0 marks the coordinates of the detected targets.
  6. target_id::Union{Int, Nothing} — ID of the target that the drone is currently attacking; nothing if the drone is in search mode.
  7. comm_range::Float64 is the communication radius of a particular drone (can be used to simulate a heterogeneous swarm).
  8. sensor_range::Float64 — The viewing radius of the specific drone.
  9. failed_attack::Bool — A flag indicating an unsuccessful attack attempt (needed to visually highlight such a drone and change its logic).
  10. target_destroyed::Bool — A flag indicating whether the drone has been destroyed.

Structure: Target

Purpose: Description of the goal's status. It is also changeable, as its status and the list of attacking drones change during the simulation.

  1. id::Int is the unique identifier of the goal.
  2. pos::Vector{Float64} — Vector with the current coordinates of the target [x, y].
  3. vel::Vector{Float64} is a vector with the current components of the target's velocity [vx, vy].
  4. required_drones::Int — The number of drones required for a simultaneous attack to destroy the target (cooperation threshold).
  5. locked_by::Vector{Int} is a vector containing id drones that are currently attacking ("captured") a given target.
  6. destroyed::Bool — A flag indicating whether the target has been destroyed.
In [ ]:
struct SimulationConfig
    world_size::Tuple{Int, Int}
    n_drones::Int
    n_targets::Int
    dt::Float64
    total_time::Int
    comm_range::Float64
    sensor_range::Float64
    drone_speed::Float64
    target_speed::Float64
    seed::Int
end
mutable struct Drone
    id::Int
    pos::Vector{Float64}
    vel::Vector{Float64}
    state::Symbol
    map::Matrix{Float64}
    target_id::Union{Int, Nothing}
    comm_range::Float64
    sensor_range::Float64
    failed_attack::Bool
    target_destroyed::Bool
end
mutable struct Target
    id::Int
    pos::Vector{Float64}
    vel::Vector{Float64}
    required_drones::Int
    locked_by::Vector{Int}
    destroyed::Bool
end

The code below initializes global constants with simulation parameters, creates an empty map, and outputs the basic experiment settings to the console for visual inspection.

In [ ]:
const config = SimulationConfig((200, 200), 10, 7, 0.5, 700, 100.0, 20.0, 3.0, 0.5, 123)
const empty_map = zeros(config.world_size...)
println("Конфигурация:")
println("  Дроны: $(config.n_drones)")
println("  Цели: $(config.n_targets)")
println("  Размер мира: $(config.world_size)")
println("  Диапазон связи: $(config.comm_range)")
println("  Диапазон сенсора: $(config.sensor_range)")
Конфигурация:
  Дроны: 10
  Цели: 7
  Размер мира: (200, 200)
  Диапазон связи: 100.0
  Диапазон сенсора: 20.0

Next, let's take a look at the auxiliary functions used in this example.

Function initialize_drones creates and returns a vector from n drones, each of which is randomly placed on one of the four boundaries of the game world and receives an initial velocity directed inside the polygon, as well as initializes all the starting parameters of the agents, including their unique identifier, an empty map, the search status and the absence of a target.

In [ ]:
function initialize_drones(n, world_size, comm_range, sensor_range)
    drones = Vector{Drone}(undef, n)
    sides = rand(1:4, n)
    for i in 1:n
        pos, vel = if sides[i] == 1
            ([rand() * world_size[1], world_size[2]], [0.0, -rand(1.0:config.drone_speed)])
        elseif sides[i] == 2
            ([world_size[1], rand() * world_size[2]], [-rand(1.0:config.drone_speed), 0.0])
        elseif sides[i] == 3
            ([rand() * world_size[1], 0.0], [0.0, rand(1.0:config.drone_speed)])
        else
            ([0.0, rand() * world_size[2]], [rand(1.0:config.drone_speed), 0.0])
        end
        drones[i] = Drone(i, pos, vel, :searching, copy(empty_map), nothing, comm_range, sensor_range, false, false)
    end
    return drones
end
Out[0]:
initialize_drones (generic function with 1 method)

Function initialize_targets creates and returns a vector from n targets, each of which is placed in a pseudo-random position in the central area of the world (with an offset from the borders) and receives a random velocity vector, as well as initializes their parameters, including the identifier, the number of drones needed to destroy (by default 1), an empty list of attackers and the status "not destroyed".

In [ ]:
function initialize_targets(n, world_size)
    targets = Vector{Target}(undef, n)
    for i in 1:n
        pos = [rand(10:world_size[1]-10), rand(10:world_size[2]-10)]
        vel = [rand(-1.0:0.1:1.0), rand(-1.0:0.1:1.0)] * config.target_speed
        targets[i] = Target(i, pos, vel, 1, Int[], false)
    end
    return targets
end
Out[0]:
initialize_targets (generic function with 1 method)

Function update_position! updates the position of the agent (drone or target) based on its speed and time step, ensures correct reflection from the boundaries by placing the agent inside the polygon and calculating a new random velocity vector directed at an angle within ± 60 degrees from the normal to the wall, but only if the agent is an active, not destroyed target.

In [ ]:
function update_position!(agent, dt, world_size)
    if !(isa(agent, Target) && agent.destroyed)
        agent.pos .+= agent.vel .* dt
        hit_wall = false
        for i in 1:2
            if agent.pos[i] < 0
                agent.pos[i] = 0
                hit_wall = true
            elseif agent.pos[i] > world_size[i]
                agent.pos[i] = world_size[i]
                hit_wall = true
            end
        end
        if hit_wall
            θ = rand(-π/3:0.1:π/3)
            speed = max(norm(agent.vel), 1.0)
            agent.vel = [cos(θ) -sin(θ); sin(θ) cos(θ)] * [speed, 0.0]
            for i in 1:2
                if agent.pos[i] == 0
                    agent.vel[i] = abs(agent.vel[i])
                elseif agent.pos[i] == world_size[i]
                    agent.vel[i] = -abs(agent.vel[i])
                end
            end
        end
    end
end
Out[0]:
update_position! (generic function with 1 method)

Function sense_and_act! implements the logic of target detection and decision-making for a single drone: if the drone is not in attack mode, has not failed a previous attack and has not previously destroyed the target, it scans the space within its viewing radius, updates its map with the coordinates of the found targets and, if the target requires more drones to attack, switches to attack mode (:diving), blocking the target and adding your ID to its list of attackers.

In [ ]:
function sense_and_act!(drone, targets, world_size)
    (drone.state == :diving || drone.failed_attack || drone.target_destroyed) && return nothing
    for target in targets
        target.destroyed && continue
        dist = norm(drone.pos - target.pos)
        if dist < drone.sensor_range
            x_idx = Int(clamp(round(target.pos[1]), 1, world_size[1]))
            y_idx = Int(clamp(round(target.pos[2]), 1, world_size[2]))
            drone.map[x_idx, y_idx] = 1.0
            if length(target.locked_by) < target.required_drones && drone.target_id === nothing
                drone.state = :diving
                drone.target_id = target.id
                push!(target.locked_by, drone.id)
                break
            end
        end
    end
end
Out[0]:
sense_and_act! (generic function with 1 method)

Function communicate! implements a mechanism for exchanging information between drones: for each pair of drones, the distance between them is checked, and if it is less than the communication radius of both, their target maps are synchronized by combining known coordinates (if the target is known to at least one drone, it becomes known to both).

In [ ]:
function communicate!(drones)
    n = length(drones)
    for i in 1:n
        for j in i+1:n
            dist = norm(drones[i].pos - drones[j].pos)
            if dist < min(drones[i].comm_range, drones[j].comm_range)
                for x in 1:size(drones[i].map, 1)
                    for y in 1:size(drones[i].map, 2)
                        if drones[i].map[x, y] == 1.0 || drones[j].map[x, y] == 1.0
                            drones[i].map[x, y] = 1.0
                            drones[j].map[x, y] = 1.0
                        end
                    end
                end
            end
        end
    end
end
Out[0]:
communicate! (generic function with 1 method)

Function dive_to_target! controls the drone attack process: if the target has already been destroyed, the drone switches to search mode, otherwise the drone moves towards the target and, when approaching a critical distance, destroys the target with a probability of 80% (if the required number of attacking drones is reached).

In [ ]:
function dive_to_target!(drone, targets, dt, world_size)
    if drone.state == :diving && drone.target_id !== nothing
        target = targets[drone.target_id]
        if target.destroyed
            drone.state = :searching
            drone.target_id = nothing
            drone.target_destroyed = true  
            return
        end
        direction = target.pos - drone.pos
        norm_dir = norm(direction)
        if norm_dir > 1.0
            drone.vel = direction / norm_dir * 5.0
        else
            drone.vel .= 0.0
            if rand() < 0.8 && length(target.locked_by) >= target.required_drones
                target.destroyed = true
                target.vel .= 0.0
                drone.target_destroyed = true 
            else
                drone.failed_attack = true
                drone.state = :searching
                drone.vel .= 0.0
                filter!(x -> x != drone.id, target.locked_by)
            end
        end
        update_position!(drone, dt, world_size)
    end
end
Out[0]:
dive_to_target! (generic function with 1 method)

Function create_plot visualizes the current state of the simulation: displays targets (red - active, gray - destroyed) with information about the number of attacking drones, drones in various states (blue - search, green - attack, gray - inactive after completing a task or failure), drone viewing areas in search mode, and also displays statistics on the number of destroyed targets attacking drones and the number of the current frame.

In [ ]:
function create_plot(drones, targets, frame, world_size, destroyed_count, attacking_count)
    p = plot(size=(800, 800), xlim=(0, world_size[1]), ylim=(0, world_size[2]), legend=false, title="Рой дронов - Кадр: $frame/$(config.total_time)")
    for target in targets
        color = target.destroyed ? :gray : :red
        scatter!([target.pos[1]], [target.pos[2]], color=color, markersize=6, marker=:square, alpha=0.8)
        if !target.destroyed
            annotate!(target.pos[1] + 3, target.pos[2] + 3, text($(target.id)($(length(target.locked_by))/$(target.required_drones))", 8))
        end
    end
    for drone in drones
        color = if drone.failed_attack || drone.target_destroyed  # внесены изменения
            :gray
        elseif drone.state == :searching
            :blue
        elseif drone.state == :diving
            if drone.target_id !== nothing && targets[drone.target_id].destroyed
                :gray
            else
                :green
            end
        else
            :purple
        end
        scatter!([drone.pos[1]], [drone.pos[2]], color=color, markersize=7, marker=:utriangle, alpha=0.9)
        if drone.state == :searching && !drone.failed_attack && !drone.target_destroyed  # внесены изменения
            plot!([drone.pos[1] + drone.sensor_range * cos(θ) for θ in 0:0.2:2π],
                  [drone.pos[2] + drone.sensor_range * sin(θ) for θ in 0:0.2:2π],
                  linecolor=color, linealpha=0.1, linewidth=1, seriestype=:path)
        end
        annotate!(drone.pos[1] - 6, drone.pos[2] - 6, text($(drone.id)", 8, color))
    end
    annotate!(5, world_size[2] - 5, text("Уничтожено: $(destroyed_count)/$(config.n_targets)", 10, :black))
    annotate!(5, world_size[2] - 15, text("Атакующие: $(attacking_count)", 10, :black))
    annotate!(5, world_size[2] - 25, text("Кадр: $frame/$(config.total_time)", 10, :black))
    return p
end
Out[0]:
create_plot (generic function with 1 method)

Function run_simulation It is the main simulation cycle: initializes drones and targets, then updates the positions of targets at each time step, performs target detection by drones, organizes communication between drones, updates the positions of active drones, visualizes the state of the system, tracks the time of target destruction and outputs the final statistics after completion of the simulation.

In [ ]:
function run_simulation()
    Random.seed!(config.seed)
    drones = initialize_drones(config.n_drones, config.world_size, config.comm_range, config.sensor_range)
    targets = initialize_targets(config.n_targets, config.world_size)
    frames_to_destroy = Int[]
    target_destroy_times = Dict{Int, Int}()
    destroyed_count = 0
    anim = @animate for frame in 1:config.total_time
        attacking_count = count(d -> d.state == :diving, drones)
        current_destroyed = count(t -> t.destroyed, targets)
        if current_destroyed > destroyed_count
            for target in targets
                if target.destroyed && !haskey(target_destroy_times, target.id)
                    target_destroy_times[target.id] = frame
                    push!(frames_to_destroy, frame)
                end
            end
            destroyed_count = current_destroyed
        end
        if destroyed_count == config.n_targets
            println("Все цели уничтожены на кадре $frame")
            break
        end
        for target in targets
            update_position!(target, config.dt, config.world_size)
        end
        for drone in drones
            sense_and_act!(drone, targets, config.world_size)
        end
        communicate!(drones)
        for drone in drones
            if drone.state == :searching && !drone.failed_attack && !drone.target_destroyed  # внесены изменения
                update_position!(drone, config.dt, config.world_size)
            elseif drone.state == :diving
                dive_to_target!(drone, targets, config.dt, config.world_size)
            end
        end
        p = create_plot(drones, targets, frame, config.world_size, destroyed_count, attacking_count)
    end every 5
    println("\n" * "="^50)
    println("ИТОГИ СИМУЛЯЦИИ")
    println("="^50)
    println("Уничтожено целей: $(destroyed_count)/$(config.n_targets)")
    if !isempty(frames_to_destroy)
        println("Среднее время уничтожения: $(round(mean(frames_to_destroy), digits=1)) кадров")
        println("Максимальное время: $(maximum(frames_to_destroy)) кадров")
        println("Минимальное время: $(minimum(frames_to_destroy)) кадров")
    end
    return anim
end
Out[0]:
run_simulation (generic function with 1 method)

Now let's run the simulation and look at its results.

In [ ]:
println("Запуск симуляции...")
animation = run_simulation()
gif(animation, "drone_swarm.gif", fps=10)
Запуск симуляции...

==================================================
ИТОГИ СИМУЛЯЦИИ
==================================================
Уничтожено целей: 5/7
Среднее время уничтожения: 177.4 кадров
Максимальное время: 457 кадров
Минимальное время: 24 кадров
[ Info: Saved animation to /user/drone_swarm.gif
Out[0]:
No description has been provided for this image

Conclusion

This project is an excellent example of simulation of complex systems with decentralized management. It teaches the principles of agent-based modeling, where each drone has its own behavior logic and local information, but is capable of cooperative interaction through limited communication.

The most interesting aspects:

  • Implementation of a collective decision-making mechanism without a central controller
  • Simulation of a limited sensory area and communication radius
  • Cooperative attack of targets requiring simultaneous participation of several drones
  • Dynamic adaptation to a changing environment and handling unsuccessful attacks
  • Real-time visualization with color indication of agent states

The code demonstrates how a simple set of rules at the individual level can generate complex group behavior at the system level, which is a key principle in swarm intelligence and multi-agent systems.