geofence-guard

A ROS 2 node that sits between whatever decides where an aircraft should go and the autopilot that takes it there. It forwards a position setpoint only when it can show that the whole straight path to it stays inside the flight envelope.

Autopilots have geofences, but they act after the breach: the vehicle crosses the line, then it returns to launch. Meanwhile the thing sending setpoints keeps getting less trustworthy: a planner that replans in flight, a teleop bridge, a language model with a goto tool. geofence-guard refuses the setpoint before the vehicle moves.

Plays the six stages below in order, about 70 seconds. Scroll or press Esc to stop.

Language
C++17, built with CMake
Core
No dependencies, no ROS; GoogleTest for the tests
Node
ROS 2 Jazzy, rclcpp
Tests
96 core and CLI tests, plus 10 node tests that run in a ROS 2 container in CI
Simulation
PX4 1.15 SITL, flown in CI on every push
License
MIT
geofence-guard check examples/field.fence examples/shortcut.csv leg 1 (10.0, 10.0, 0.0) -> (20.0, 20.0, 30.0) ok leg 2 (20.0, 20.0, 30.0) -> (20.0, 230.0, 30.0) ok leg 3 (20.0, 230.0, 30.0) -> (280.0, 100.0, 30.0) REFUSED path_leaves_fence: path crosses the boundary of the fence

All three are real runs of the repo's own CLI on its own example files, built from commit 29ccaba. The CLI makes every decision with the same core the ROS 2 node runs.

1Refuse an envelope that could be misread

The envelope is a small text file: a frame, a margin, an altitude band, a keep-in fence polygon and any number of keep-out zones. Together they describe a prism, a polygon extruded between two altitudes.

  • A misspelt key is an error, not a skipped line. If max_alitude 60 were ignored, a limit someone wrote down would silently not be enforced.
  • A key set twice, a number with units like 5m, nan, a zero margin, or a polygon that crosses or touches itself all stop the node from starting.
  • Numbers are read with from_chars, not strtod, so a node started under a decimal-comma locale still reads 2.5 as two and a half.

The node also refuses to start with no envelope at all. A guard with no limits looks like protection and gives none.

One field, six ways to get it wrong Illustrative input

    Files accepted
    0
    Files refused, exit 2
    0
    field.fence and typo.fence are the repo's own files. The five other variants were made for this page by changing one thing in field.fence; every error is the real output of geofence-guard validate, verbatim, with the shell's exit code.

    2Run every check, and report every failure

    Each setpoint is checked against the vehicle's last known position, through nine checks with stable reason codes. All the violations are reported, not just the first, so a log line says everything that was wrong with a move.

    • Three checks end the decision on the spot: a NaN or infinite value, a frame other than the envelope's, and a vehicle position older than max_state_age. After any of them, nothing else can be reasoned about.
    • The rest all run: altitude band, leg length, target in the fence, path in the fence, target clear of each exclusion zone, path clear of each.
    • The guard is stateless. Every input is an argument, so a decision can be replayed exactly from its log line.

    The same bad setpoint, fresh and stale Illustrative input

    SetpointVehicle at (20, 230, 30), asked to go to (280, 100, 150) in map

      The setpoint was made up for this page. The decision is real: the repo's core Guard::check, called from a small harness compiled against it, with the reason codes and details as it returned them. The envelope is examples/field.fence, where max_state_age is 0.5 s.

      3Prove the whole path, not just the target

      This is the line. A setpoint crosses it only when every point on the straight segment from the vehicle to the target stays at least margin from every boundary. The vehicle starts at least that far inside, and a segment can't cross a boundary it never gets within margin of, so this proves the whole leg, target included.

      • The check is exact segment-to-edge distance. It doesn't sample points along the path, so a thin keep-out zone can't slip between two samples.
      • The fence can be any simple polygon, convex or not. Non-convex is where a target-only check fails: both ends inside, the line between them outside.
      • Exclusion zones run from the ground to the ceiling, so flying over the mast is refused too.

      The autopilot doesn't fly a perfect straight line. The margin is what covers tracking error, so the README says to set it from the vehicle's measured cross-track error, not from a guess.

      An L-shaped field with a mast near its south edge

      • Fence
      • 5 m margin band
      • Mast, keep out
      • Accepted leg
      • Refused leg

      Geometry from examples/field.fence. The shortcut is the repo's examples/shortcut.csv; the Illustrative input near miss is a one-leg mission written for this page. Both outputs are real geofence-guard check runs, verbatim. The map is drawn by this page from those files and those decisions.

      4Let a vehicle in the margin find its way back

      The path rule has a trap. If wind pushes the vehicle 2 m from the fence with a 5 m margin, every path out of that spot starts inside the margin, so every setpoint is refused, including the one that would bring it back. A guard that pins the vehicle against the fence is not safe.

      • When the vehicle isn't safely inside a region, a recovery rule applies to that region, and the decision says recovery.
      • The target must still be fully safe. The path may cross the boundary only as often as it needs to on the way back: never from inside the band, once from outside it.
      • A path that comes back in, leaves across the notch and comes in again counts three crossings, and is refused. Through a vertex counts as two, conservative on purpose.

      Four ways back Illustrative input

      Vehicle starts
      Boundary crossings
      0
      Allowed
      Decision
      The four one-leg missions were written for this page; the field is the repo's examples/field.fence. Each output line is a real geofence-guard check run, verbatim. Crossing points are drawn by this page; the counts in the output are the guard's.

      ClearedAbove this line a setpoint is only a request. Below it, the autopilot gets an answer: the setpoint unchanged, a hold, or nothing at all.

      5Hold where the refusal began, or go quiet

      An accepted setpoint goes out unchanged. A refused one is replaced by a hold at the vehicle's position when the first refusal happened, and every decision is published as one JSON line on ~/decisions, with every reason.

      • The hold point is latched until a setpoint is accepted. Recomputed from each new odometry message, a stream of refusals would chase the drift instead of stopping it.
      • If the guard doesn't know where the vehicle is (no odometry yet, odometry too old, or in the wrong frame), it publishes nothing. A hold from a stale position could be anywhere; silence lets the autopilot's offboard-loss failsafe take over.
      • Freshness is timed on the guard's own clock from when odometry arrived, not from the message stamp, which a bridge may set on a different time base.

      The CI smoke test's four setpoints, vehicle at (20, 230, 30)

        Inputs are the ones scripts/smoke.sh publishes in CI. ROS 2 isn't installed where this page was made, so the node itself was not run: each line is the core Guard's real decision, printed in the node's JSON format with the node's forward, hold or drop rule copied from guard_node.cpp. The position ages, 0.2 s and then 1.2 s once odometry stops, are Illustrative input. The shortcut line matches the README's example byte for byte.

        6Fly it in PX4 SITL, with and without the guard

        CI flies one mission twice in PX4 1.15 software-in-the-loop through MAVROS, with the same naive planner: once straight to the autopilot, once through geofence-guard. The pass condition is about where the vehicle went, measured from the logged odometry, not from what the guard says it did.

        • Every airborne odometry sample of the guarded run must be inside the fence and outside the exclusion zone.
        • The guarded run must have refused something, or it didn't exercise the guard. The unguarded run must leave the fence, or the scenario has drifted and the guarded result proves nothing.
        • The first SITL run caught a fault in the test's planner, not the guard: it switched to AUTO.LAND while still moving fast, and the landing overshot to 4.9 m from the fence, inside the margin. In that mode the guard has no authority, so the planner now waits until the vehicle has nearly stopped.

        The committed SITL run, docs/sitl-results.json

        Without the guard

        With geofence-guard

        Outside the fence
        11.8 s / 0 s
        Furthest outside
        42.7 m / n/a
        Closest to the fence
        n/a / 8.25 m
        Waypoints refused
        0 / 1
        Margin
        5 m
        Numbers are the repo's committed docs/sitl-results.json (without / with), measured by sitl/analyze.py from logged odometry; this page didn't re-fly it (it needs Docker and a 5 GB PX4 image). Lines show the planned legs from sitl/mission.csv on sitl/field.fence, the example field shifted 10 m; they are not flown tracks.

        96 tests in the core, 10 more for the node

        The core suite is 87 GoogleTest cases and 9 tests that run the CLI as a program, since its exit codes and output are its interface. All 96 pass with make test, and again under AddressSanitizer and UBSan with make check. The 10 node tests need ROS 2 and run in CI.

          Checked against brute force

          • Property tests: 20,000 random worlds, each with a non-convex fence, an optional exclusion zone, a random margin and a random move. Each decision is compared with a 2,000-step walk along the actual line.
          • Soundness: everything the guard accepts, the walk confirms. Usefulness: every move the walk shows has clearance to spare, the guard accepts. A guard that refused everything would pass the first and fail the second.
          • Minimum counts: the first generator made almost no safe moves, so soundness had too few accepted cases to mean anything. Each branch now asserts a minimum count, so a generator change that hollows the tests out fails the build.
          • The node: 10 gtest cases drive it over real topics: forwarding, the hold and its latch, dropping before odometry, stale odometry, both frame mismatches, NaN, hold disabled, and no envelope.
          • The launched package: CI starts the installed node with ros2 launch and drives it with ros2 topic pub.

          CI builds the core with GCC and Clang with -Werror, runs the sanitizer build, the ROS 2 job in ros:jazzy, and the two SITL flights.

          Deliberately not done

          Real flight
          It has flown in simulation only: PX4 1.15, a simulated quadcopter, no wind, one mission. A real aircraft in gusts will track worse, and nothing here measures by how much. ArduPilot hasn't been tried.
          Speed and attitude limits
          It limits where, not how fast. Those belong to the autopilot.
          Latitude and longitude
          It works in a local metric frame. Projecting a geodetic fence into the odometry frame is left to the user.
          Velocity setpoints
          Only position setpoints are checked, so velocity and acceleration setpoints must not be routed through it.
          Being the last line
          Keep the autopilot's own geofence on, set slightly outside this one. The guard prevents breaches; the autopilot contains the ones that happen anyway.

          Run it

          git clone https://github.com/ahmed-hashim-pro/geofence-guard.git
          cd geofence-guard
          
          # The core and CLI: a C++17 compiler, CMake, GoogleTest.
          make test
          ./build/geofence-guard check examples/field.fence examples/shortcut.csv
          ./build/geofence-guard render examples/field.fence examples/survey.csv > survey.svg
          
          # The ROS 2 node: needs Docker, not a ROS install.
          make ros

          check exits 0 when every leg passes, 1 when any is refused, and 2 when a file is unusable. make sitl flies the PX4 comparison; it needs Docker and pulls an image of about 5 GB.