build: add directory-oriented make workflows
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled

This commit is contained in:
Onur Özkan committed 2026-08-30 07:05:01 +03:00
1 parent 4bc98adbc6
commit 7dc3ccb619
40 files changed
+1367 -278

No files matched your search

+3 -1
View File
@@ -16,7 +16,9 @@ kaptan-flight-config = { path = "../../libs/kaptan-flight-config" }
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
kaptan-log = { path = "../../libs/kaptan-log" }
kaptan-math = { path = "../../libs/kaptan-math" }
kaptan-navigation = { path = "../../libs/kaptan-navigation", features = ["sitl"] }
kaptan-navigation = { path = "../../libs/kaptan-navigation", features = [
"sitl",
] }
kaptan-safety = { path = "../../libs/kaptan-safety" }
kaptan-time = { path = "../../libs/kaptan-time" }
minicbor = "0.25"
+34
View File
@@ -0,0 +1,34 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override PYTHON := python3
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
run: ## Run kaptan-sim with the remaining command-line arguments
@exec python3 ../../../scripts/make-argv.py exec run ./kaptan-sim
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
.DEFAULT:
@:
else
.PHONY: help run _check
help: ## Show simulation commands
@printf '%s\n' 'Simulation' '' \
'Application arguments are passed through the root Makefile:' \
' make firmware/tools/sitl -- move 5 3 1 --wait'
_check:
$(PYTHON) world-structure-test.py
$(PYTHON) -m unittest test_kaptan_sim.py
endif
+12 -12
View File
@@ -12,18 +12,18 @@ compose_file="$project_root/tools/sitl/compose.yaml"
python3 "$project_root/tools/sitl/world-structure-test.py"
cleanup() {
status=$?
trap - EXIT
# Preserve the first failing command's result, but retain enough evidence
# to distinguish a client/lease failure from a Gazebo or container start
# failure in CI. `down` otherwise removes the only useful diagnostics.
if [ "$status" -ne 0 ]; then
echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2
docker compose -f "$compose_file" ps >&2 || true
docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true
fi
docker compose -f "$compose_file" down --remove-orphans || true
exit "$status"
status=$?
trap - EXIT
# Preserve the first failing command's result, but retain enough evidence
# to distinguish a client/lease failure from a Gazebo or container start
# failure in CI. `down` otherwise removes the only useful diagnostics.
if [ "$status" -ne 0 ]; then
echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2
docker compose -f "$compose_file" ps >&2 || true
docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true
fi
docker compose -f "$compose_file" down --remove-orphans || true
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
+42 -17
View File
@@ -50,9 +50,13 @@ GROUND_PLANE_TOLERANCE_METRES = 0.10
def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
subcommands = parser.add_subparsers(dest="command", required=True)
ready = subcommands.add_parser("ready", help="wait for estimator and simulated sensors")
ready = subcommands.add_parser(
"ready", help="wait for estimator and simulated sensors"
)
ready.add_argument("--timeout", type=float, default=20.0)
move = subcommands.add_parser("move", help="command absolute local north/east/up metres")
move = subcommands.add_parser(
"move", help="command absolute local north/east/up metres"
)
move.add_argument("north", type=float)
move.add_argument("east", type=float)
move.add_argument("up", type=float)
@@ -78,9 +82,15 @@ def arguments() -> argparse.Namespace:
"land",
help="descend to an explicit local landing point and revoke virtual motor authority",
)
land.add_argument("north", type=float, help="absolute local north landing coordinate in metres")
land.add_argument("east", type=float, help="absolute local east landing coordinate in metres")
land.add_argument("--wait", action="store_true", help="wait for the terminal landed result")
land.add_argument(
"north", type=float, help="absolute local north landing coordinate in metres"
)
land.add_argument(
"east", type=float, help="absolute local east landing coordinate in metres"
)
land.add_argument(
"--wait", action="store_true", help="wait for the terminal landed result"
)
land.add_argument(
"--verify-truth",
action="store_true",
@@ -122,7 +132,9 @@ def latest_simulation_time(
if magic != b"KSC1" or version != VERSION:
continue
if latest is not None and simulation_time < latest:
raise RuntimeError("Gazebo simulation clock reset; restart the command in the new epoch")
raise RuntimeError(
"Gazebo simulation clock reset; restart the command in the new epoch"
)
latest = simulation_time
@@ -145,7 +157,10 @@ def wait_ready(socket_: socket.socket, timeout: float) -> None:
if latest is not None and latest & READY == READY:
return
if latest is None:
raise RuntimeError("host SITL is not running; run docker compose -f tools/sitl/compose.yaml up -d --build")
raise RuntimeError(
"host SITL is not running; run docker compose -f "
"firmware/tools/sitl/compose.yaml up -d --build from the repository root"
)
names = (
(ESTIMATOR_READY, "estimator initialization"),
(IMU_FRESH, "fresh simulator IMU"),
@@ -174,10 +189,7 @@ def truth_sample_within_tolerance(
or velocity > velocity_tolerance
or attitude_error > attitude_tolerance
or (require_stopped_motors and not motors_stopped)
or (
require_ground_plane
and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES
)
or (require_ground_plane and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES)
)
@@ -217,11 +229,16 @@ def verify_truth(
keepalive is not None
and control_socket is not None
and simulation_time is not None
and (next_keepalive_time is None or simulation_time >= next_keepalive_time)
and (
next_keepalive_time is None
or simulation_time >= next_keepalive_time
)
):
control_socket.sendto(keepalive, CONTROL)
next_keepalive_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS
payload = recv(truth_socket, min(acquisition_deadline, time.monotonic() + 0.01))
payload = recv(
truth_socket, min(acquisition_deadline, time.monotonic() + 0.01)
)
if payload is None or len(payload) != TRUTH_PACKET.size:
continue
(
@@ -261,8 +278,12 @@ def verify_truth(
attitude_w**2 + attitude_x**2 + attitude_y**2 + attitude_z**2
)
if not math.isfinite(quaternion_norm) or abs(quaternion_norm - 1.0) > 1e-3:
raise RuntimeError("Gazebo truth contained a non-unit attitude quaternion")
attitude_error = 2.0 * math.acos(min(1.0, abs(attitude_w) / quaternion_norm))
raise RuntimeError(
"Gazebo truth contained a non-unit attitude quaternion"
)
attitude_error = 2.0 * math.acos(
min(1.0, abs(attitude_w) / quaternion_norm)
)
motors = (motor_1, motor_2, motor_3, motor_4)
maximum_motor_fraction = max(abs(motor) for motor in motors)
motors_stopped = all(abs(motor) <= 0.01 for motor in motors)
@@ -449,7 +470,9 @@ def command_goal(
if magic != b"KSL1" or version != VERSION or received_id != goal_id:
continue
if lifecycle == GOAL_REJECTED:
raise RuntimeError(f"{action} was rejected because SITL was not estimator-ready")
raise RuntimeError(
f"{action} was rejected because SITL was not estimator-ready"
)
if lifecycle == GOAL_ACTIVE:
active = True
print(f"{action} active: north={north:g} m, east={east:g} m, up={up:g} m")
@@ -512,7 +535,9 @@ def main() -> int:
or not math.isfinite(values.truth_hold_seconds)
)
if tolerances:
raise ValueError("truth tolerances and hold duration must be finite and positive")
raise ValueError(
"truth tolerances and hold duration must be finite and positive"
)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.bind(USER_CONTROL)
if values.command == "ready":
+24 -17
View File
@@ -37,7 +37,9 @@ class TruthAcceptanceTests(unittest.TestCase):
)
def test_move_truth_contract_allows_an_armed_hover(self):
self.assertTrue(self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False))
self.assertTrue(
self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False)
)
def test_landing_truth_contract_requires_zero_motors_and_ground_plane(self):
self.assertFalse(self.acceptable(motors_stopped=False))
@@ -77,23 +79,28 @@ class TruthAcceptanceTests(unittest.TestCase):
arguments = command_goal.call_args.args
self.assertEqual(arguments[0:2], (control_socket, clock_socket))
magic, version, goal_id, north, east, up = self.client.GOAL.unpack(arguments[2])
self.assertEqual((magic, version, north, east, up), (b"KSD1", 1, 1.0, -2.0, 0.0))
self.assertEqual(
(magic, version, north, east, up), (b"KSD1", 1, 1.0, -2.0, 0.0)
)
self.assertEqual(arguments[3], goal_id)
self.assertEqual(arguments[4:], (
1.0,
-2.0,
0.0,
60.0,
True,
True,
0.75,
0.25,
0.35,
0.75,
self.client.GOAL_LANDED,
True,
"land",
))
self.assertEqual(
arguments[4:],
(
1.0,
-2.0,
0.0,
60.0,
True,
True,
0.75,
0.25,
0.35,
0.75,
self.client.GOAL_LANDED,
True,
"land",
),
)
if __name__ == "__main__":
+21 -7
View File
@@ -67,7 +67,9 @@ def check_world() -> None:
world = root.find("./world")
if world is None:
fail("missing world")
model_names = {model_element.get("name") for model_element in world.findall("model")}
model_names = {
model_element.get("name") for model_element in world.findall("model")
}
if model_names != {"ground", "sitl_reference_map", "kaptan_sitl_quad"}:
fail(
"the declared flat-world model may contain only ground, the visual reference map, "
@@ -76,7 +78,9 @@ def check_world() -> None:
ground = model(root, "ground")
ground_collisions = ground.findall(".//collision")
if [collision.get("name") for collision in ground_collisions] != ["ground_collision"]:
if [collision.get("name") for collision in ground_collisions] != [
"ground_collision"
]:
fail("ground must retain exactly one flat collision plane")
if ground_collisions[0].find("geometry/plane") is None:
fail("ground collision must remain a flat plane")
@@ -89,11 +93,15 @@ def check_world() -> None:
if reference_map.findall(".//collision"):
fail("reference map must remain visual-only and cannot add obstacle physics")
if reference_map.findall(".//transparency"):
fail("reference map must not use unsupported SDF transparency material children")
fail(
"reference map must not use unsupported SDF transparency material children"
)
reference_link = named(reference_map, "link", "reference_link")
actual_marks = {visual.get("name") for visual in reference_link.findall("visual")}
if actual_marks != {"landing_pad"}:
fail(f"reference map may contain only the landing pad; found {sorted(actual_marks)!r}")
fail(
f"reference map may contain only the landing pad; found {sorted(actual_marks)!r}"
)
quad = model(root, "kaptan_sitl_quad")
if pose(quad)[:3] != (0.0, 0.0, 0.14):
@@ -122,7 +130,9 @@ def check_world() -> None:
"body_collision",
*(f"landing_foot_{foot}_collision" for foot in foot_positions),
}
actual_quad_collisions = {collision.get("name") for collision in base_link.findall("collision")}
actual_quad_collisions = {
collision.get("name") for collision in base_link.findall("collision")
}
if actual_quad_collisions != expected_quad_collisions:
fail(
"quad collision geometry must remain its body and four declared landing feet; "
@@ -130,8 +140,12 @@ def check_world() -> None:
)
sensors = quad.findall(".//sensor")
if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [("imu", "imu")]:
fail("this local-state SITL world must not imply unmodeled visual/range/flow sensors")
if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [
("imu", "imu")
]:
fail(
"this local-state SITL world must not imply unmodeled visual/range/flow sensors"
)
if __name__ == "__main__":