feat(simulation): add Worker AI polygon runtime and terrain navigation

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:45 +03:00
parent a7c64e009d
commit f01bd39037
88 changed files with 9918 additions and 108 deletions
@@ -0,0 +1,34 @@
FROM ros:humble-ros-base-jammy
LABEL com.nodedc.product="mission-core" \
com.nodedc.stack="ai-polygon" \
com.nodedc.role="terrain-navigation" \
com.nodedc.managed-by="ai-polygon-worker" \
org.opencontainers.image.source="https://github.com/HongbiaoZ/autonomous_exploration_development_environment" \
org.opencontainers.image.revision="158e67b31b644ed1e8b06eb1d7f70e183cc62591"
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates build-essential python3-numpy python3-yaml \
python3-colcon-common-extensions ros-humble-pcl-ros \
ros-humble-pcl-conversions ros-humble-tf2-geometry-msgs \
ros-humble-sensor-msgs-py ros-humble-rclpy \
&& rm -rf /var/lib/apt/lists/*
# Upstream's terrain analysis and collision-free motion primitives are unchanged.
# Keep the exact sources and their declared BSD package licenses in the image.
WORKDIR /opt/cmu
RUN curl -fL --retry 3 \
https://codeload.github.com/HongbiaoZ/autonomous_exploration_development_environment/tar.gz/158e67b31b644ed1e8b06eb1d7f70e183cc62591 \
-o /tmp/upstream.tgz \
&& mkdir upstream src \
&& tar -xzf /tmp/upstream.tgz -C upstream --strip-components=1 \
&& cp -a upstream/src/local_planner upstream/src/terrain_analysis src/ \
&& source /opt/ros/humble/setup.bash \
&& MAKEFLAGS=-j2 colcon build --base-paths src --parallel-workers 1 \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF \
&& rm /tmp/upstream.tgz
ENV ROS_LOCALHOST_ONLY=1 ROS_DOMAIN_ID=83
WORKDIR /adapter
ENTRYPOINT ["/bin/bash", "-c", "source /opt/ros/humble/setup.bash && source /opt/cmu/install/setup.bash && exec python3 /adapter/navigation/server.py"]
@@ -0,0 +1,8 @@
FROM ndc-ai-polygon-cmu:rectangle-v3
COPY terrain_connectivity.cpp /opt/missioncore/terrain_connectivity.cpp
RUN g++ -O3 -std=c++17 -ffp-contract=off -fno-fast-math -fPIC -shared \
/opt/missioncore/terrain_connectivity.cpp -o /opt/missioncore/libterrain_connectivity.so
LABEL com.nodedc.product="mission-core" \
com.nodedc.stack="ai-polygon" \
com.nodedc.role="terrain-navigation" \
com.nodedc.managed-by="ai-polygon-worker"
@@ -0,0 +1,16 @@
# Reuse the already-built pinned upstream image. The caller supplies its exact
# sha256 through a local immutable build tag; no model images are replaced.
ARG CMU_BASE
FROM ${CMU_BASE}
LABEL com.nodedc.product="mission-core" \
com.nodedc.stack="ai-polygon" \
com.nodedc.role="terrain-navigation" \
com.nodedc.managed-by="ai-polygon-worker" \
com.nodedc.navigation-adapter="rectangular-primitive-filter-v3"
COPY missioncore_footprint.hpp /opt/cmu/src/local_planner/src/missioncore_footprint.hpp
COPY patch_footprint.py /opt/cmu/patch_footprint.py
WORKDIR /opt/cmu
RUN python3 patch_footprint.py && source /opt/ros/humble/setup.bash \
&& MAKEFLAGS=-j2 colcon build --base-paths src --packages-select local_planner \
--parallel-workers 1 --cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
WORKDIR /adapter
@@ -0,0 +1,17 @@
param([string]$Root = 'D:\NDC_MISSIONCORE\runtime\simulation')
$ErrorActionPreference = 'Continue'
$base = 'sha256:d9b9c842bf8f0bf5208d8568d35f41b69d6e4a9ed74f31dedbcd455f1f7f263f'
$actual = docker image inspect 'ndc-ai-polygon-cmu:rectangle-v3' --format '{{.Id}}'
if ($LASTEXITCODE -ne 0 -or $actual.Trim() -ne $base) { throw 'Prepare the pinned rectangular CMU image first' }
$endpoint = docker context inspect --format '{{.Endpoints.docker.Host}}'
if ($LASTEXITCODE -ne 0) { throw 'Docker endpoint unavailable' }
$config = Join-Path $Root 'state\connectivity-public-build'
New-Item -ItemType Directory -Force $config | Out-Null
[IO.File]::WriteAllText((Join-Path $config 'config.json'), '{"auths":{"https://index.docker.io/v1/":{}}}')
$env:DOCKER_CONFIG = $config
$env:BUILDX_CONFIG = Join-Path $config 'buildx'
docker --config $config --host $endpoint.Trim() build --progress plain -t ndc-ai-polygon-cmu:connectivity-v1 -f (Join-Path $PSScriptRoot 'Dockerfile.connectivity') $PSScriptRoot
if ($LASTEXITCODE -ne 0) { throw 'Terrain connectivity build failed' }
$image = docker --config $config --host $endpoint.Trim() image inspect ndc-ai-polygon-cmu:connectivity-v1 --format '{{.Id}}'
if ($LASTEXITCODE -ne 0) { throw 'Built image unavailable' }
@{base_image=$base; image=$image.Trim(); source_sha256=(Get-FileHash (Join-Path $PSScriptRoot 'terrain_connectivity.cpp') -Algorithm SHA256).Hash.ToLower(); installed_at=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json
@@ -0,0 +1,34 @@
param([string]$Root = 'D:\NDC_MISSIONCORE\runtime\simulation')
# Windows PowerShell 5 treats Docker's normal stderr progress as ErrorRecords.
# Native failures are checked by exit code after each command below.
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$tag = 'ndc/mission-core-ai-module-cmu-navigation:158e67b3-v1'
$receipt = Join-Path $Root 'assets\cmu-navigation-v1.json'
$existing = docker image inspect $tag --format '{{.Id}}' 2>$null
if ($LASTEXITCODE -ne 0) {
# Public upstream images need no credentials. Windows SSH cannot access the
# interactive user's credential helper; isolate only this build's config.
$endpoint = docker context inspect --format '{{.Endpoints.docker.Host}}'
if ($LASTEXITCODE -ne 0) { throw 'Docker endpoint unavailable' }
$buildConfig = Join-Path $PSScriptRoot 'public-build-config'
New-Item -ItemType Directory -Force $buildConfig | Out-Null
# An explicit anonymous registry prevents CLI auto-discovery of wincred.
[IO.File]::WriteAllText((Join-Path $buildConfig 'config.json'), '{"auths":{"https://index.docker.io/v1/":{}}}')
$env:DOCKER_CONFIG = $buildConfig
$env:BUILDX_CONFIG = Join-Path $buildConfig 'buildx'
docker --config $buildConfig --host $endpoint.Trim() build --progress plain -t $tag -f (Join-Path $PSScriptRoot 'Dockerfile') $PSScriptRoot
if ($LASTEXITCODE -ne 0) { throw 'CMU navigation image build failed' }
$existing = docker image inspect $tag --format '{{.Id}}'
if ($LASTEXITCODE -ne 0) { throw 'Built navigation image is unavailable' }
}
$value = @{
schema_version = 'missioncore.ai-polygon-navigation-install/v1'
upstream = 'HongbiaoZ/autonomous_exploration_development_environment'
upstream_commit = '158e67b31b644ed1e8b06eb1d7f70e183cc62591'
image = $existing.Trim()
tag = $tag
installed_at = [DateTime]::UtcNow.ToString('o')
}
[IO.File]::WriteAllText($receipt, ($value | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false))
$value | ConvertTo-Json -Compress
@@ -0,0 +1,24 @@
param([string]$Root = 'D:\NDC_MISSIONCORE\runtime\simulation')
$ErrorActionPreference = 'Continue'
$base = 'sha256:7693c28fac83df9e9e6ac6d89ffec81470f660cb8b30fc78fa88be228329dc0b'
$baseTag = 'ndc-ai-polygon-cmu:upstream-158e67b'
$tag = 'ndc-ai-polygon-cmu:rectangle-v3'
$actual = docker image inspect $base --format '{{.Id}}'
if ($LASTEXITCODE -ne 0 -or $actual.Trim() -ne $base) { throw 'Prepare the pinned upstream CMU image first' }
docker tag $base $baseTag
if ($LASTEXITCODE -ne 0) { throw 'Unable to identify the local CMU build base' }
docker build --build-arg "CMU_BASE=$baseTag" -f (Join-Path $PSScriptRoot 'Dockerfile.footprint') -t $tag $PSScriptRoot
if ($LASTEXITCODE -ne 0) { throw 'Rectangular candidate filter build failed' }
$image = docker image inspect $tag --format '{{.Id}}'
if ($LASTEXITCODE -ne 0) { throw 'Built navigation image unavailable' }
$value = @{
schema_version = 'missioncore.ai-polygon-navigation-install/v1'
upstream_commit = '158e67b31b644ed1e8b06eb1d7f70e183cc62591'
base_image = $base
image = $image.Trim()
adapter = 'rectangular-primitive-filter-v3'
adapter_sha256 = (Get-FileHash (Join-Path $PSScriptRoot 'missioncore_footprint.hpp') -Algorithm SHA256).Hash.ToLower()
installed_at = [DateTime]::UtcNow.ToString('o')
}
[IO.File]::WriteAllText((Join-Path $Root 'assets\cmu-navigation-rectangle-v3.json'), ($value | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
$value | ConvertTo-Json -Compress
@@ -0,0 +1,74 @@
param(
[Parameter(Mandatory=$true)][string]$WorldFile,
[string]$Root = 'D:\NDC_MISSIONCORE\runtime\simulation',
[string]$Python = 'D:\NDC_MISSIONCORE\runtime\simulation\isaac-sim-6.1.0\kit\python\python.exe'
)
$ErrorActionPreference = 'Stop'
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::InvariantCulture
$world = Get-Content -Raw $WorldFile | ConvertFrom-Json
if ($world.sha256 -notmatch '^[a-f0-9]{64}$') { throw 'Invalid source hash' }
$source = Join-Path $Root ('state\worlds\' + $world.sha256 + '.ply')
if ((Get-FileHash $source -Algorithm SHA256).Hash.ToLower() -ne $world.sha256) { throw 'World source changed' }
$tool = Join-Path $Root 'tools\splat-transform-3.4.0\node_modules\@playcanvas\splat-transform'
$package = Get-Content -Raw (Join-Path $tool 'package.json') | ConvertFrom-Json
if ($package.version -ne '3.4.0') { throw 'SplatTransform version changed' }
$settings = $world.settings
$json = ($settings | ConvertTo-Json -Compress) + (Get-FileHash $PSCommandPath -Algorithm SHA256).Hash
$sha = [Security.Cryptography.SHA256]::Create()
$digest = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($json))).Replace('-','').ToLower()
$out = Join-Path $Root ('assets\terrain-v1\' + $world.sha256 + '-' + $digest.Substring(0,16))
New-Item -ItemType Directory -Force $out | Out-Null
$mesh = Join-Path $out 'terrain.collision.glb'
$migrator = Join-Path $PSScriptRoot 'migrate_terrain_coordinates.py'
if ((Get-FileHash $migrator -Algorithm SHA256).Hash.ToLower() -ne 'ff08de8b97b9f2b0040b379840c402f5c7c327e3bc1915548d99b6372ff5ca37') { throw 'Coordinate migrator identity changed' }
if (-not (Test-Path $mesh)) {
# Exact rigid correction of the admitted historical cache avoids rerunning
# a faulty third-party BVH build. Never mutates or reclassifies that mesh.
$ErrorActionPreference = 'Continue'
& $Python $migrator --root $Root --world $WorldFile --output $mesh
if ($LASTEXITCODE -notin @(0,2)) { throw 'Collision coordinate migration failed' }
$ErrorActionPreference = 'Stop'
}
if (-not (Test-Path $mesh)) {
# SplatTransform 3.4 assigns PLY an implicit Rz(180) before CLI actions.
# Cancel it FIRST, then apply the same XYZ rotation as the USD visual.
# World Z-up -> glTF Y-up is Rx(-90): (x,y,z) -> (x,z,-y).
# Runtime's (x,y,z) -> (x,-z,y) is exactly its inverse. Do not use Rx(+90).
$x = [double]$settings.spawn_xy[0]; $y = [double]$settings.spawn_xy[1]; $z = [double]$settings.ground_z
$box = @(($x-15),($z-4),(-$y-15),($x+15),($z+8),(-$y+15)) -join ','
$args = @('--max-old-space-size=12288', (Join-Path $tool 'bin\cli.mjs'), $source,
'--filter-nan', '--rotate', '0,0,180',
'--rotate', ($settings.rotation_degrees -join ','),
'--scale', [string]$settings.meters_per_unit,
'--rotate', '-90,0,0', '--filter-box', $box,
'--voxel-size', '0.06', '--voxel-opacity', '0.25',
'--voxel-floor-fill', '0.3', '--collision-mesh', 'smooth',
(Join-Path $out 'terrain.voxel.json'))
$ErrorActionPreference = 'Continue'
& node @args
if ($LASTEXITCODE -ne 0) { throw 'SplatTransform collision generation failed' }
$ErrorActionPreference = 'Stop'
}
if (-not (Test-Path $mesh)) { throw 'Collision mesh was not produced' }
$correctionPath = Join-Path $out 'coordinate-correction.json'
$correction = if (Test-Path $correctionPath) { Get-Content -Raw $correctionPath | ConvertFrom-Json } else { $null }
if ($correction) {
if ((Get-FileHash $mesh -Algorithm SHA256).Hash.ToLower() -ne $correction.collider_sha256) { throw 'Corrected collision changed' }
$settings = $correction.settings
}
$manifest = @{
schema_version = 'missioncore.ai-polygon-terrain/v1'
source_sha256 = $world.sha256
settings = $settings
generator = '@playcanvas/splat-transform@3.4.0'
generator_sha256 = (Get-FileHash $PSCommandPath -Algorithm SHA256).Hash.ToLower()
voxel_size_m = 0.06
collider = $mesh
collider_sha256 = (Get-FileHash $mesh -Algorithm SHA256).Hash.ToLower()
coordinate_system = 'gltf-y-up-from-metric-world'
coordinate_revision = 'ply-rz180-cancelled-usd-xyz-gltf-rx-minus90-v2'
coordinate_correction = $correction
qualification = 'reconstructed-proxy-requires-contact-validation'
}
[IO.File]::WriteAllText((Join-Path $out 'terrain.json'), ($manifest | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false))
$manifest | ConvertTo-Json -Depth 6 -Compress
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>worker_loopback</transport_id>
<type>UDPv4</type>
<sendBufferSize>1048576</sendBufferSize>
<receiveBufferSize>1048576</receiveBufferSize>
<interfaceWhiteList><address>127.0.0.1</address></interfaceWhiteList>
</transport_descriptor>
</transport_descriptors>
<participant profile_name="worker_navigation" is_default_profile="true">
<rtps>
<userTransports><transport_id>worker_loopback</transport_id></userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
</profiles>
@@ -0,0 +1,107 @@
"""Metre-square swept-body check on CMU's observed terrain and chosen path.
CMU's circular path table proposes paths. This final adapter check preserves
the actual square chassis, including the initial turn, without widening it to
its circumscribed circle for straight travel. No scene geometry enters here.
"""
import math
import numpy as np
MAX_STEP_M = 0.10
FRAME_DEADLINE_SECONDS = 0.8
def _obstacles(terrain, pose):
terrain = np.asarray(terrain, dtype=float)
obstacles = terrain[terrain[:, 3] > MAX_STEP_M + 1e-4, :2] - np.asarray(pose[:2])
x, y, z, w = pose[3:]
yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
return obstacles @ np.array([[math.cos(yaw), -math.sin(yaw)], [math.sin(yaw), math.cos(yaw)]])
def _clearances(obstacles):
return np.maximum(np.abs(obstacles[:, 0]) - 0.5, np.abs(obstacles[:, 1]) - 0.5)
def _clear(obstacles, position, angle):
delta = obstacles - position
c, s = math.cos(angle), math.sin(angle)
along = delta[:, 0] * c + delta[:, 1] * s
across = -delta[:, 0] * s + delta[:, 1] * c
initial = _clearances(obstacles)
if np.any(initial <= 0):
return False # Never excuse an overlap of the actual metre-square body.
clearance = np.maximum(np.abs(along) - 0.5, np.abs(across) - 0.5)
# An observed point may already be inside the 5 cm reserve behind the body.
# Permit only motion that never decreases that initial clearance. This
# cannot authorize moving toward it, reversing into it or corner penetration.
return bool(np.all(clearance + 1e-6 >= np.minimum(initial, 0.05)))
def command_footprint_clear(speed, yaw_rate, terrain, pose):
"""Collision monitor over deadman latency plus a conservative braking arc.
CMU replans the route continuously. A later blocked corner must not prevent
safe progress on its prefix; this checks the command that can actually be
applied before the source-frame deadline, plus braking and 0.5 s reserve.
"""
obstacles = _obstacles(terrain, pose)
horizon = FRAME_DEADLINE_SECONDS + 0.5 + abs(speed) / 0.4 + abs(yaw_rate) / 1.6
for t in np.arange(0, horizon + 0.025, 0.025):
angle = yaw_rate * t
position = (
np.array([speed * math.sin(angle) / yaw_rate, speed * (1 - math.cos(angle)) / yaw_rate])
if abs(yaw_rate) > 1e-6
else np.array([speed * t, 0])
)
if not _clear(obstacles, position, angle):
return False
return True
def regulate_command(speed, yaw_rate, terrain, pose):
"""Reduce speed along CMU's same arc when its full-speed stop is unsafe.
Scaling both components preserves curvature. The shortened stopping envelope
is a prefix of the original arc, so search for its largest admitted scale.
Never choose another turn/direction, ignore a hazard or creep arbitrarily.
"""
if command_footprint_clear(speed, yaw_rate, terrain, pose):
return speed, yaw_rate, 1.0
low, high = 0.2, 1.0
if not command_footprint_clear(speed * low, yaw_rate * low, terrain, pose):
return 0.0, 0.0, 0.0
for _ in range(7):
middle = (low + high) / 2
if command_footprint_clear(speed * middle, yaw_rate * middle, terrain, pose):
low = middle
else:
high = middle
return speed * low, yaw_rate * low, low
def swept_footprint_clear(path, terrain, pose):
path = np.asarray(path, dtype=float)[:, :2]
terrain = np.asarray(terrain, dtype=float)
if len(path) < 2 or terrain.ndim != 2 or terrain.shape[1] != 4:
return False
obstacles = _obstacles(terrain, pose)
previous_angle = 0.0
for start, end in zip(path[:-1], path[1:], strict=True):
delta = end - start
length = np.linalg.norm(delta)
if length < 1e-6:
continue
angle = math.atan2(delta[1], delta[0])
turn = math.atan2(math.sin(angle - previous_angle), math.cos(angle - previous_angle))
for fraction in np.linspace(0, 1, max(2, math.ceil(abs(turn) / 0.035) + 1)):
if not _clear(obstacles, start, previous_angle + fraction * turn):
return False
for fraction in np.linspace(0, 1, max(2, math.ceil(length / 0.025) + 1)):
if not _clear(obstacles, start + fraction * delta, angle):
return False
previous_angle = angle
return True
@@ -0,0 +1,120 @@
"""Correct only the known v1 cache basis; original collision assets are immutable.
For the admitted [-90,0,180] scene rotation, the old and correct pipelines
have identical vertical axes and differ by a horizontal 180-degree rotation.
Rotating the finished mesh is exact: no filtering, smoothing or new geometry.
"""
import argparse
import hashlib
import json
import struct
from pathlib import Path
LEGACY_GENERATOR = "d7e142184c1de43cde71ebc7cd311e667c18c7c7a17725c14e123127740697a2"
def rotate_glb(data):
magic, version, size = struct.unpack_from("<III", data)
if (magic, version, size) != (0x46546C67, 2, len(data)):
raise ValueError("Invalid collision GLB")
chunks = {}
offset = 12
while offset < size:
length, kind = struct.unpack_from("<II", data, offset)
chunks[kind] = data[offset + 8 : offset + 8 + length]
offset += 8 + length
document = json.loads(chunks[0x4E4F534A])
binary = bytearray(chunks[0x004E4942])
if any(
any(k in node for k in ("matrix", "rotation", "translation", "scale"))
for node in document.get("nodes", [])
):
raise ValueError("Collision transforms must be baked")
transformed = set()
for mesh in document["meshes"]:
for primitive in mesh["primitives"]:
if set(primitive["attributes"]) != {"POSITION"}:
raise ValueError("Only the known position-only collision export is admitted")
index = primitive["attributes"]["POSITION"]
if index in transformed:
continue
transformed.add(index)
accessor = document["accessors"][index]
view = document["bufferViews"][accessor["bufferView"]]
if (
accessor["componentType"] != 5126
or accessor["type"] != "VEC3"
or "sparse" in accessor
or view.get("buffer", 0) != 0
):
raise ValueError("Unsupported collision position buffer")
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
stride = view.get("byteStride", 12)
for i in range(accessor["count"]):
at = start + i * stride
x, y, z = struct.unpack_from("<fff", binary, at)
struct.pack_into("<fff", binary, at, -x, y, -z)
low, high = accessor["min"], accessor["max"]
accessor["min"] = [-high[0], low[1], -high[2]]
accessor["max"] = [-low[0], high[1], -low[2]]
encoded = json.dumps(document, separators=(",", ":")).encode()
encoded += b" " * ((-len(encoded)) % 4)
result = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(encoded) + 8 + len(binary))
return (
result
+ struct.pack("<II", len(encoded), 0x4E4F534A)
+ encoded
+ struct.pack("<II", len(binary), 0x004E4942)
+ binary
)
def migrate(root, world, output):
settings = world["settings"]
if settings["rotation_degrees"] != [-90, 0, 180]:
return False
for path in sorted((root / "assets/terrain-v1").glob(world["sha256"] + "-*/terrain.json")):
manifest = json.loads(path.read_text(encoding="utf-8-sig"))
old = manifest["settings"]
if (
manifest.get("generator_sha256") != LEGACY_GENERATOR
or old["rotation_degrees"] != [-90, 0, 180]
or old["meters_per_unit"] != settings["meters_per_unit"]
):
continue
centre = [-v for v in old["spawn_xy"]]
if (
any(abs(centre[i] - settings["spawn_xy"][i]) > 12 for i in (0, 1))
or abs(old["ground_z"] - settings["ground_z"]) > 2
):
continue
original = Path(manifest["collider"]).read_bytes()
if hashlib.sha256(original).hexdigest() != manifest["collider_sha256"]:
raise ValueError("Legacy collision identity changed")
corrected = rotate_glb(original)
temporary = output.with_suffix(".part.glb")
temporary.write_bytes(corrected)
temporary.replace(output)
report = {
"source_manifest": str(path),
"source_collider_sha256": manifest["collider_sha256"],
"collider_sha256": hashlib.sha256(corrected).hexdigest(),
"transform": "gltf-rotate-y-180",
"settings": {**old, "spawn_xy": centre},
}
output.with_name("coordinate-correction.json").write_text(
json.dumps(report, indent=2), encoding="utf-8"
)
return True
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--world", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if not migrate(args.root, json.loads(args.world.read_text(encoding="utf-8-sig")), args.output):
raise SystemExit(2)
@@ -0,0 +1,74 @@
// Mission Core adapter for CMU's existing motion-primitive selector.
// Reject a candidate before selection when its initial turn or first metre
// sweeps the real rectangular body through observed terrain. Upstream's
// angular wedge alone misses obstacles beside a rear corner.
#pragma once
#include <algorithm>
#include <cmath>
#include <vector>
#include <utility>
template <class TerrainCloud, class PathCloud>
bool missioncoreFootprintClear(const TerrainCloud& terrain, const PathCloud& path,
double rotation, double scale, double range,
double length, double width, double threshold,
bool twoWayDrive) {
const double margin = 0.05;
const double halfLength = length / 2.0 - margin, halfWidth = width / 2.0 - margin;
const double cap = std::min(range, 1.2);
std::vector<std::pair<double, double>> obstacles;
for (const auto& p : terrain.points) {
if (p.intensity > threshold && std::hypot(p.x, p.y) < cap + std::hypot(halfLength + margin, halfWidth + margin))
obstacles.emplace_back(p.x, p.y);
}
auto clear = [&](double x, double y, double yaw) {
const double c = std::cos(yaw), s = std::sin(yaw);
for (const auto& p : obstacles) {
const double dx = p.first - x, dy = p.second - y;
const double initial = std::max(std::abs(p.first) - halfLength,
std::abs(p.second) - halfWidth);
if (initial <= 0) return false;
const double clearance = std::max(std::abs(dx * c + dy * s) - halfLength,
std::abs(-dx * s + dy * c) - halfWidth);
if (clearance + 1e-6 < std::min(margin, initial)) return false;
}
return true;
};
auto turnClear = [&](double x, double y, double from, double to) {
const double delta = std::atan2(std::sin(to - from), std::cos(to - from));
const int steps = std::max(1, int(std::ceil(std::abs(delta) / 0.025)));
for (int i = 0; i <= steps; ++i)
if (!clear(x, y, from + delta * i / steps)) return false;
return true;
};
if (path.points.size() < 2 || !clear(0, 0, 0)) return false;
// Match pathFollower's 0.7 m look-ahead. Its initial command points here,
// which need not equal the selected primitive's initial tangent.
double lookYaw = rotation;
for (const auto& p : path.points) {
lookYaw = rotation + std::atan2(p.y, p.x);
if (std::hypot(p.x, p.y) * scale >= std::min(0.7, cap)) break;
}
// A reversing body follows the rear-facing primitive without first turning
// 180 degrees. Match pathFollower's twoWayDrive direction selection.
const bool reverse = twoWayDrive && std::cos(lookYaw) < 0;
if (reverse) lookYaw += M_PI;
if (!turnClear(0, 0, 0, lookYaw)) return false;
const double c = std::cos(rotation), s = std::sin(rotation);
double lastX = 0, lastY = 0, lastYaw = lookYaw;
for (const auto& p : path.points) {
if (std::hypot(p.x, p.y) * scale > cap) break;
const double x = scale * (c * p.x - s * p.y);
const double y = scale * (s * p.x + c * p.y);
const double distance = std::hypot(x - lastX, y - lastY);
if (distance < 1e-6) continue;
const double yaw = std::atan2(y - lastY, x - lastX) + (reverse ? M_PI : 0);
if (!turnClear(lastX, lastY, lastYaw, yaw)) return false;
const int steps = std::max(1, int(std::ceil(distance / 0.025)));
for (int i = 1; i <= steps; ++i)
if (!clear(lastX + (x - lastX) * i / steps,
lastY + (y - lastY) * i / steps, yaw)) return false;
lastX = x; lastY = y; lastYaw = yaw;
}
return true;
}
@@ -0,0 +1,14 @@
"""Apply one explicit adapter patch to the pinned CMU selector; fail on drift."""
from pathlib import Path
path = Path("/opt/cmu/src/local_planner/src/localPlanner.cpp")
source = path.read_text()
needle = " maxScore = clearPathPerGroupScore[i];\n selectedGroupID = i;"
replacement = """\
if (!missioncoreFootprintClear(*plannerCloudCrop, *startPaths[i % groupNum],
rotAng, pathScale, std::min(pathRange, relativeGoalDis),
vehicleLength, vehicleWidth, obstacleHeightThre, twoWayDrive)) continue;
maxScore = clearPathPerGroupScore[i];
selectedGroupID = i;"""
assert source.count(needle) == 1, "CMU selector source changed"
path.write_text('#include "missioncore_footprint.hpp"\n' + source.replace(needle, replacement))
+294
View File
@@ -0,0 +1,294 @@
"""Bounded synthetic acceptance of the installed CMU image on Worker only.
This tests navigation independently of perception and physics. It does not
claim Gaussian-world or physical-rover acceptance. Owns one temporary container.
"""
import argparse
import http.client
import json
import math
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--adapter", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
profile = json.loads((args.adapter / "models.worker-006.json").read_text())
name = "ndc-mission-core-ai-module-navigation-check-" + uuid4().hex[:8]
identity = subprocess.check_output(
[
"docker",
"run",
"-d",
"--name",
name,
"--cpus",
"3",
"--memory",
"2g",
"--label",
"com.nodedc.stack=ai-polygon-qualification",
"--label",
"com.nodedc.product=mission-core",
"--label",
"com.nodedc.role=qualification",
"--label",
"com.nodedc.managed-by=ai-polygon-qualification",
"-p",
"127.0.0.1:18193:8010",
"--mount",
f"type=bind,source={args.adapter},target=/adapter,readonly",
profile["navigation"]["image"],
],
text=True,
).strip()
connection = http.client.HTTPConnection("127.0.0.1", 18193, timeout=3)
def request(path, body=None):
connection.request(
"GET" if body is None else "POST", path, body=None if body is None else json.dumps(body)
)
response = connection.getresponse()
value = json.loads(response.read())
if response.status != 200:
raise RuntimeError(str(value))
return value
def ready():
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
request("/ready")
return
except (OSError, RuntimeError, http.client.HTTPException):
time.sleep(0.2)
raise TimeoutError("CMU qualification container did not become ready")
report = {
"image": profile["navigation"]["image"],
"cases": [],
"utc": datetime.now(UTC).isoformat(),
"monotonic": time.monotonic(),
}
try:
ready()
floor = [[x / 10, y / 10, 0.0] for x in range(-20, 41) for y in range(-20, 21)]
wall = [[1.5, y / 10, z / 10] for y in range(-20, 21) for z in range(1, 16)]
enclosure = [
[side * 0.9, y / 10, z / 10]
for side in (-1, 1)
for y in range(-9, 10)
for z in range(1, 16)
]
enclosure += [
[x / 10, side * 0.9, z / 10]
for side in (-1, 1)
for x in range(-9, 10)
for z in range(1, 16)
]
def corridor(half_width):
return floor + [
[x / 10, side * half_width, z / 10]
for x in range(-15, 51)
for side in (-1, 1)
for z in range(1, 16)
]
for name, points in (
("clear-flat-ground", floor),
("rear-reserve-forward-escape", floor + [[-0.53, 0, z / 10] for z in range(1, 10)]),
("front-reserve-stop", floor + [[0.53, 0, z / 10] for z in range(1, 10)]),
("body-overlap-stop", floor + [[-0.49, 0, z / 10] for z in range(1, 10)]),
("reverse-clear-ground", floor),
(
"reverse-blocked-by-rear-wall",
floor + [[-0.7, y / 10, z / 10] for y in range(-10, 11) for z in range(1, 12)],
),
("turn-away-from-wall", floor + wall),
(
"close-wall-stop",
floor + [[0.65, y / 10, z / 10] for y in range(-20, 21) for z in range(1, 16)],
),
("closed-enclosure-stop", floor + enclosure),
("metre-rover-in-1.3m-corridor", corridor(0.65)),
("metre-rover-rejects-0.9m-corridor", corridor(0.45)),
(
"rear-corner-selects-clear-primitive",
floor + [[-0.62, -0.39, z / 10] for z in range(1, 15)],
),
):
request("/reset", {})
ready()
samples = []
for _ in range(20):
samples.append(
request(
"/plan",
{
"points": points,
"pose": [0, 0, 0.27, 0, 0, 0, 1],
"goal": [-0.65, 0, 0]
if name.startswith("reverse-")
else ([2, -2, 0] if name.startswith("rear-corner") else [3, 0, 0]),
"allow_reverse": name.startswith("reverse-"),
"max_speed_mps": 0.15,
},
)
)
time.sleep(0.2)
if name in (
"clear-flat-ground",
"metre-rover-in-1.3m-corridor",
"rear-reserve-forward-escape",
):
passed = any(v["speed_mps"] > 0.05 and v["status"] == "path" for v in samples)
elif name == "reverse-clear-ground":
passed = any(v["speed_mps"] < -0.05 and v["status"] == "path" for v in samples[-5:])
elif name == "turn-away-from-wall":
valid = [v for v in samples[-5:] if v["status"] == "path"]
passed = len(valid) >= 3 and all(
(v["speed_mps"] > 0.02 or abs(v["yaw_rate_rps"]) > 0.1)
and max(p[0] for p in v["path"]) < 0.75
for v in valid
)
elif name.startswith("rear-corner"):
passed = any(v["speed_mps"] > 0.05 for v in samples[-5:]) and all(
v.get("diagnostic", {}).get("failure") != "footprint" for v in samples[-5:]
)
else:
passed = all(v["speed_mps"] == 0 and v["status"] == "blocked" for v in samples[-5:])
report["cases"].append({"name": name, "passed": passed, "samples": samples})
# A vanished return is occlusion, not proof of free space. Observe a
# near wall, then only distant ground for longer than the decay timer.
request("/reset", {})
ready()
close_wall = [[0.7, y / 10, z / 10] for y in range(-10, 11) for z in range(1, 10)]
samples = []
for i in range(30):
samples.append(
request(
"/plan",
{
"points": floor + close_wall if i < 5 else [p for p in floor if p[0] > 1.2],
"pose": [0, 0, 0.37, 0, 0, 0, 1],
"goal": [3, 0, 0],
"max_speed_mps": 0.15,
},
)
)
time.sleep(0.2)
report["cases"].append(
{
"name": "occluded-near-obstacle-retained",
"samples": samples,
"passed": all(
x["speed_mps"] == 0 and x["status"] == "blocked" for x in samples[-5:]
),
}
)
# Navigation must admit the same continuous grades as the measured
# physical profile, while retaining a discontinuous 15 cm ledge.
for angle, quantized in [(a, False) for a in (5, 10, 15, 20, 25)] + [
(10, True),
(20, True),
]:
request("/reset", {})
ready()
radians = math.radians(angle)
slope = math.tan(radians)
samples = []
for _ in range(12):
samples.append(
request(
"/plan",
{
"points": [
[x, y, round(x * slope / 0.06) * 0.06 if quantized else x * slope]
for x, y, _ in floor
],
"pose": [
0,
0,
0.37,
0,
-math.sin(radians / 2),
0,
math.cos(radians / 2),
],
"goal": [3, 0, 3 * slope],
"max_speed_mps": 0.15,
},
)
)
time.sleep(0.2)
report["cases"].append(
{
"name": f"supported-{'voxel-' if quantized else ''}ramp-{angle}-degrees",
"samples": samples,
"passed": any(s["speed_mps"] > 0.05 for s in samples[-5:]),
}
)
for height in (0.12, 0.15, -0.4):
request("/reset", {})
ready()
points = [[x, y, height if x >= 0.7 else 0] for x, y, _ in floor]
points += [
[x / 10, side * 0.65, z / 10]
for x in range(-15, 41)
for side in (-1, 1)
for z in range(1, 16)
]
samples = []
for _ in range(12):
samples.append(
request(
"/plan",
{
"points": points,
"pose": [0, 0, 0.37, 0, 0, 0, 1],
"goal": [3, 0, 0],
"max_speed_mps": 0.15,
},
)
)
time.sleep(0.2)
report["cases"].append(
{
"name": f"discontinuous-height-{height:+.2f}m-stop",
"samples": samples,
"passed": all(
s["speed_mps"] == 0 and s["status"] == "blocked" for s in samples[-5:]
),
}
)
report["passed"] = all(case["passed"] for case in report["cases"])
finally:
report["container_log_tail"] = subprocess.run(
["docker", "logs", "--tail", "30", identity], capture_output=True, text=True
).stderr
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
connection.close()
subprocess.run(["docker", "rm", "-f", identity], check=True, capture_output=True)
print(
json.dumps(
{
"passed": report["passed"],
"cases": [
{"name": row["name"], "passed": row["passed"]} for row in report["cases"]
],
}
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,31 @@
"""Worker-only numeric equivalence at hazard boundaries and disconnected patches."""
import argparse
import json
from pathlib import Path
import numpy as np
import terrain_costs as costs
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
assert costs._NATIVE is not None, "Compiled implementation must be installed"
engine = costs._NATIVE
rng = np.random.default_rng(230923)
cases = []
for dtype in (np.float32, np.float64):
for _ in range(200):
points = rng.uniform(-0.4, 0.4, (rng.integers(8, 180), 3)).astype(dtype)
points[:, 2] *= 0.3
cases.append(points)
for distance in (0.119999, 0.12, 0.120001):
for jump in (0.100099, 0.1001, 0.100101):
cases.append(np.array([[0, 0, 0], [distance, 0, jump]], dtype=dtype))
for points in cases:
costs._NATIVE = None
expected = costs.connected_grade(points)
costs._NATIVE = engine
assert costs.connected_grade(points) == expected
args.output.write_text(json.dumps({"passed": True, "cases": len(cases)}))
print(json.dumps({"passed": True, "cases": len(cases)}))
@@ -0,0 +1,50 @@
"""Bounded Windows snapshot read/replace acceptance; no simulator required."""
import json
import sys
import tempfile
import threading
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from local_state import StateChannel
def main():
errors = []
done = threading.Event()
with tempfile.TemporaryDirectory(prefix="ndc-polygon-ipc-") as folder:
directory = Path(folder)
writer = StateChannel(directory)
writer.write("snapshot", {"sequence": -1})
def reader():
channel = StateChannel(directory)
try:
while not done.is_set():
row = channel.read("snapshot")
if row is None or not -1 <= row["sequence"] < 1000:
raise RuntimeError("Incomplete snapshot read")
except Exception as exc:
errors.append(type(exc).__name__ + ": " + str(exc))
finally:
channel.close()
thread = threading.Thread(target=reader)
thread.start()
try:
for sequence in range(1000):
writer.write("snapshot", {"sequence": sequence, "telemetry": [sequence] * 100})
finally:
done.set()
thread.join()
if errors:
raise RuntimeError(str(errors))
if writer.read("snapshot")["sequence"] != 999:
raise RuntimeError("Final snapshot was not retained")
writer.close()
print(json.dumps({"passed": True, "concurrent_replacements": 1000}))
if __name__ == "__main__":
main()
@@ -0,0 +1,57 @@
"""Offline numeric check of the pinned GOOSE decoder on retained Worker RGB.
Runs in the existing pinned image, without changing its runner or checkpoint.
This diagnoses the adapter; it is not semantic accuracy or navigation acceptance.
"""
import argparse
import csv
import hashlib
import importlib.util
import json
import time
from pathlib import Path
import numpy as np
import torch
from PIL import Image
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--image", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
spec = importlib.util.spec_from_file_location("reference", "/assets/ddrnet-goose-runner.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
model, _, _ = module.load_model("ddrnet", Path("/assets/ddrnet-checkpoint.pth"))
tensor, _ = module.preprocess(Image.open(args.image))
tensor = tensor.cuda()
with torch.inference_mode():
logits = module.logits_from_output(model(tensor)).float()
legacy = torch.sigmoid(logits).argmax(1)
direct = logits.argmax(1)
saturated = (torch.sigmoid(logits) == 1).sum(1)
names = {}
with open("/assets/ddrnet-goose-mapping.csv") as stream:
names = {int(r["label_key"]): r["class_name"] for r in csv.DictReader(stream)}
args.output.mkdir(exist_ok=True, parents=True)
report = {
"source_sha256": hashlib.sha256(args.image.read_bytes()).hexdigest(),
"monotonic_ns": time.monotonic_ns(),
"logit_range": [float(logits.min()), float(logits.max())],
"changed_pixels": int((legacy != direct).sum()),
"saturated_tie_pixels": int((saturated > 1).sum()),
}
for name, mask in (("reference", legacy), ("direct", direct)):
mask = mask[0].cpu().numpy().astype(np.uint8)
Image.fromarray(mask).save(args.output / (name + ".png"))
ids, counts = np.unique(mask, return_counts=True)
report[name] = {names[int(i)]: int(counts[index]) for index, i in enumerate(ids)}
(args.output / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report))
if __name__ == "__main__":
main()
@@ -0,0 +1,105 @@
"""Offline authoring check for a stable, metre-wide start on a scan proxy.
Uses world geometry only to prepare a scene. No candidate map enters navigation.
An operator/engineer still verifies the chosen start against the visual trail.
"""
import argparse
import json
import math
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from spawn_clearance import obstructing_triangles
from terrain import load_glb
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--terrain", type=Path, required=True)
args = parser.parse_args()
manifest = json.loads((args.terrain / "terrain.json").read_text(encoding="utf-8-sig"))
settings = manifest["settings"]
points, indices = load_glb(args.terrain / "terrain.collision.glb")
triangles = points[indices]
low, high = triangles.min(axis=1), triangles.max(axis=1)
center = np.array(settings["spawn_xy"])
selected = (
(low[:, :2] <= center + 2.5).all(axis=1)
& (high[:, :2] >= center - 2.5).all(axis=1)
& (low[:, 2] < settings["ground_z"] + 1.5)
& (high[:, 2] > settings["ground_z"] - 0.8)
)
triangles, low, high = triangles[selected], low[selected], high[selected]
def heights(x, y):
hits = triangles[
(low[:, 0] <= x) & (high[:, 0] >= x) & (low[:, 1] <= y) & (high[:, 1] >= y)
]
if not len(hits):
return np.empty(0)
a, b, c = hits[:, 0], hits[:, 1], hits[:, 2]
den = (b[:, 1] - c[:, 1]) * (a[:, 0] - c[:, 0]) + (c[:, 0] - b[:, 0]) * (a[:, 1] - c[:, 1])
valid = np.abs(den) > 1e-8
a, b, c, den = a[valid], b[valid], c[valid], den[valid]
u = ((b[:, 1] - c[:, 1]) * (x - c[:, 0]) + (c[:, 0] - b[:, 0]) * (y - c[:, 1])) / den
v = ((c[:, 1] - a[:, 1]) * (x - c[:, 0]) + (a[:, 0] - c[:, 0]) * (y - c[:, 1])) / den
inside = (u >= -1e-6) & (v >= -1e-6) & (u + v <= 1 + 1e-6)
return (u * a[:, 2] + v * b[:, 2] + (1 - u - v) * c[:, 2])[inside]
angle = math.radians(settings["heading_degrees"])
rotation = np.array([[math.cos(angle), -math.sin(angle)], [math.sin(angle), math.cos(angle)]])
footprint = np.array([[x, y] for x in (-0.5, 0, 0.5) for y in (-0.5, 0, 0.5)]) @ rotation.T
candidates = []
for dx in np.arange(-2, 2.01, 0.2):
for dy in np.arange(-2, 2.01, 0.2):
position = center + [dx, dy]
support = []
for x, y in footprint + position:
z = heights(x, y)
near = z[np.abs(z - settings["ground_z"]) < 0.8]
if not len(near):
break
ground = near.max()
if np.any((z > ground + 0.12) & (z < ground + 1)):
break
support.append(float(ground))
if len(support) != 9:
continue
design = np.column_stack((footprint, np.ones(9)))
plane = np.linalg.lstsq(design, np.asarray(support), rcond=None)[0]
residual = float(np.max(np.abs(design @ plane - support)))
slope = math.degrees(math.atan(np.linalg.norm(plane[:2])))
if residual > 0.08 or slope > 20:
continue
if obstructing_triangles(
points, indices, position, settings["heading_degrees"], plane
):
continue
candidates.append(
{
"xy": position.tolist(),
"ground_z": float(np.median(support)),
"height_span": max(support) - min(support),
"offset_m": math.hypot(dx, dy),
"residual_m": residual,
"slope_degrees": slope,
}
)
candidates.sort(key=lambda row: row["offset_m"] + 2 * row["height_span"])
print(
json.dumps(
{
"world_sha256": manifest["source_sha256"],
"candidate_count": len(candidates),
"candidates": candidates[:12],
}
)
)
if __name__ == "__main__":
main()
+121
View File
@@ -0,0 +1,121 @@
"""Replay retained sensor evidence against the shipped CMU adapter on Worker.
Owns exactly one bounded CPU container. Never starts rendering or model GPU jobs.
"""
import argparse
import hashlib
import http.client
import json
import subprocess
import time
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
import numpy as np
def main():
p = argparse.ArgumentParser()
p.add_argument("--adapter", type=Path, required=True)
p.add_argument("--run", type=Path, required=True)
p.add_argument("--output", type=Path, required=True)
p.add_argument("--limit", type=int, default=180)
p.add_argument("--terrain", action="store_true")
args = p.parse_args()
profile = json.loads((args.adapter / "models.worker-006.json").read_text())
identity = subprocess.check_output(
[
"docker",
"run",
"-d",
"--name",
"ndc-ai-polygon-navigation-replay-" + uuid4().hex[:8],
"--cpus",
"3",
"--memory",
"2g",
"--label",
"com.nodedc.product=mission-core",
"--label",
"com.nodedc.stack=ai-polygon-qualification",
"--label",
"com.nodedc.role=qualification",
"--label",
"com.nodedc.managed-by=ai-polygon-qualification",
"-p",
"127.0.0.1:18193:8010",
"--mount",
f"type=bind,source={args.adapter},target=/adapter,readonly",
profile["navigation"]["image"],
],
text=True,
).strip()
connection = http.client.HTTPConnection("127.0.0.1", 18193, timeout=3)
source = args.run / "camera/decisions.jsonl"
rows = [json.loads(line) for line in source.read_text().splitlines()]
report = dict(
utc=datetime.now(UTC).isoformat(),
monotonic=time.monotonic(),
input_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
samples=[],
)
try:
deadline = time.monotonic() + 20
while True:
try:
connection.request("GET", "/ready")
response = connection.getresponse()
response.read()
if response.status == 200:
break
except (OSError, http.client.HTTPException):
connection.close()
if time.monotonic() > deadline:
raise TimeoutError("Replay navigation unavailable")
time.sleep(0.2)
for row in rows[: args.limit]:
if row["goal"] is None:
continue
observation = np.load(args.run / "camera" / f"{row['frame_id']:08d}.range.npz")
payload = dict(
points=observation["points"].tolist(),
pose=observation["pose"].tolist(),
goal=row["goal"],
max_speed_mps=0.15,
allow_reverse=row.get("mission", {}).get("state") == "reversing",
include_terrain=args.terrain,
)
connection.request("POST", "/plan", body=json.dumps(payload))
response = connection.getresponse()
result = json.loads(response.read())
if response.status != 200:
raise RuntimeError(result)
report["samples"].append(
dict(
frame=row["frame_id"],
pose=payload["pose"],
original=row["decision"],
result=result,
)
)
time.sleep(0.18)
finally:
connection.close()
subprocess.run(["docker", "rm", "-f", identity], check=True, capture_output=True)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
failures = Counter(
v["result"].get("diagnostic", {}).get("failure", v["result"]["status"])
for v in report["samples"]
)
blocked = [v for v in report["samples"] if v["result"]["status"] == "blocked"]
print(
json.dumps(dict(failures=failures, first_blocked=blocked[:1], last=report["samples"][-1:]))
)
if __name__ == "__main__":
main()
+449
View File
@@ -0,0 +1,449 @@
"""Bounded local HTTP adapter for the unchanged CMU ROS 2 navigation nodes.
Only simulated sensor observations enter ROS; no scene mesh or oracle route.
The container is owned by one episode. A reset restarts all causal ROS state.
"""
import json
import math
import os
import signal
import subprocess
import sys
import threading
import time
from collections import OrderedDict
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import numpy as np
import rclpy
from footprint import MAX_STEP_M, regulate_command
from geometry_msgs.msg import PointStamped, TwistStamped
from nav_msgs.msg import Odometry
from nav_msgs.msg import Path as RosPath
from rclpy.node import Node
from sensor_msgs.msg import PointCloud2, PointField
from sensor_msgs_py import point_cloud2
from std_msgs.msg import Float32, Header
from terrain_costs import TerrainCostNormalizer, underbody_support_costs
def stamp_ns(stamp):
return stamp.sec * 1_000_000_000 + stamp.nanosec
class Navigation(Node):
def __init__(self):
super().__init__("missioncore_navigation_adapter")
self.condition = threading.Condition()
self.processes = []
self.path = self.command = self.terrain = None
self.odom = self.create_publisher(Odometry, "/state_estimation", 5)
self.scan = self.create_publisher(PointCloud2, "/registered_scan", 5)
self.goal = self.create_publisher(PointStamped, "/way_point", 5)
self.speed = self.create_publisher(Float32, "/speed", 5)
self.obstacles = self.create_publisher(PointCloud2, "/added_obstacles", 5)
self.surface = self.create_publisher(PointCloud2, "/terrain_map", 5)
self.create_subscription(RosPath, "/path", self.on_path, 5)
self.create_subscription(TwistStamped, "/cmd_vel", self.on_command, 5)
self.create_subscription(PointCloud2, "/terrain_map_raw", self.on_terrain, 5)
self.slope_corrected = 0
self.terrain_processing_ms = 0.0
self.normalize_costs = TerrainCostNormalizer()
self.support_poses = OrderedDict()
self.underbody_corrected = 0
self.start_nodes()
def start_nodes(self):
common = dict(
autonomyMode=True,
autonomySpeed=0.3,
maxSpeed=1.0,
twoWayDrive=True,
joyToSpeedDelay=0.0,
)
configs = [
(
"terrain_analysis",
"terrainAnalysis",
dict(
scanVoxelSize=0.06,
# Keep the upstream near-field memory: an obstacle hidden
# by our own chassis must not disappear after one second.
decayTime=2.0,
noDecayDis=4.0,
useSorting=True,
# Keep CMU's upstream ground quantile. Lower values make
# shallow scan depressions the reference for the entire
# 0.6 m neighbourhood; the median admits too much wall.
quantileZ=0.25,
considerDrop=True,
clearDyObs=False,
noDataObstacle=False,
vehicleHeight=0.9,
minRelZ=-2.0,
maxRelZ=1.0,
voxelPointUpdateThre=1,
voxelTimeUpdateThre=0.0,
),
),
(
"local_planner",
"localPlanner",
dict(
**common,
pathFolder="/opt/cmu/install/local_planner/share/local_planner/paths",
# Match the final monitor's 5 cm margin on every side;
# otherwise CMU repeatedly proposes a forbidden corner turn.
vehicleLength=1.1,
vehicleWidth=1.1,
useTerrainAnalysis=True,
checkObstacle=True,
# The pinned rectangular-filter image checks the complete
# initial turn and primitive before selection. The upstream
# angular wedge can wrongly exclude a clear straight escape
# from an obstacle beside the rear corner.
checkRotObstacle=False,
adjacentRange=5.0,
obstacleHeightThre=MAX_STEP_M,
groundHeightThre=0.08,
costHeightThre=0.08,
useCost=True,
pointPerPathThre=1,
terrainVoxelSize=0.08,
minRelZ=-0.5,
maxRelZ=0.9,
# Propose with a 56 cm half-width. The final swept square
# check below covers front/rear corners and turning.
pathScale=1.25,
minPathScale=1.25,
pathScaleBySpeed=False,
pathRangeBySpeed=False,
# Permit a safe short prefix when a full metre is obstructed.
# The swept-body monitor still covers command latency and
# braking; a prefix is not permission to cross its endpoint.
# Upstream decrements range by 0.5 m by default, so merely
# lowering the minimum skips every shorter candidate.
minPathRange=0.2,
pathRangeStep=0.1,
dirThre=80.0,
goalClearRange=0.0,
),
),
(
"local_planner",
"pathFollower",
dict(
**common,
lookAheadDis=0.7,
yawRateGain=2.0,
stopYawRateGain=2.0,
maxYawRate=20.0,
maxAccel=0.4,
dirDiffThre=0.3,
# The follower sees the cropped local prefix, not the
# mission endpoint. Do not stop before its 0.2 m minimum;
# waypoint arrival and the braking monitor remain separate.
stopDisThre=0.08,
slowDwnDisThre=0.7,
useInclToStop=True,
inclThre=30.0,
stopTime=0.5,
noRotAtGoal=True,
pubSkipNum=0,
),
),
]
for package, executable, parameters in configs:
args = ["ros2", "run", package, executable, "--ros-args"]
if executable == "terrainAnalysis":
args += ["-r", "/terrain_map:=/terrain_map_raw"]
for key, value in parameters.items():
args += ["-p", f"{key}:={str(value).lower() if isinstance(value, bool) else value}"]
self.processes.append(subprocess.Popen(args, start_new_session=True))
def stop_nodes(self):
for process in self.processes:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
for process in self.processes:
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
self.processes.clear()
def ready(self):
return (
len(self.processes) == 3
and all(p.poll() is None for p in self.processes)
and self.odom.get_subscription_count() >= 3
and self.scan.get_subscription_count() >= 2
)
def on_path(self, message):
with self.condition:
self.path = (
stamp_ns(message.header.stamp),
time.monotonic(),
[[p.pose.position.x, p.pose.position.y, p.pose.position.z] for p in message.poses],
)
self.condition.notify_all()
def on_command(self, message):
with self.condition:
self.command = (
stamp_ns(message.header.stamp),
time.monotonic(),
message.twist.linear.x,
message.twist.angular.z,
)
self.condition.notify_all()
def on_terrain(self, message):
started = time.monotonic()
points = point_cloud2.read_points_numpy(
message, field_names=["x", "y", "z", "intensity"], skip_nans=True
).copy()
points, corrected = self.normalize_costs(points)
with self.condition:
# CMU round-trips the stamp through double seconds. Match the same
# sub-microsecond tolerance as the observation transaction below;
# never substitute an unrelated latest pose for a delayed map.
stamp = stamp_ns(message.header.stamp)
support = next(
(
value
for key, value in reversed(self.support_poses.items())
if abs(key - stamp) <= 1000
),
None,
)
underbody = 0
if support is not None:
points, underbody = underbody_support_costs(points, *support)
fields = [
PointField(name=name, offset=i * 4, datatype=PointField.FLOAT32, count=1)
for i, name in enumerate(("x", "y", "z", "intensity"))
]
self.surface.publish(point_cloud2.create_cloud(message.header, fields, points))
with self.condition:
self.terrain = (stamp_ns(message.header.stamp), len(points), points)
self.slope_corrected = corrected
self.underbody_corrected = underbody
self.terrain_processing_ms = (time.monotonic() - started) * 1000
self.condition.notify_all()
def plan(self, value):
if not self.ready():
raise RuntimeError("navigation nodes are not ready")
points = np.asarray(value["points"], dtype=np.float32)
pose = np.asarray(value["pose"], dtype=np.float64)
goal = np.asarray(value["goal"], dtype=np.float64)
speed = float(value["max_speed_mps"])
reverse = value.get("allow_reverse", False)
contact_height = float(value.get("body_contact_height_m", 0.37))
if (
points.ndim != 2
or points.shape[1] != 3
or not 50 <= len(points) <= 30000
or pose.shape != (7,)
or goal.shape != (3,)
or not 0 <= speed <= 1
or not isinstance(reverse, bool)
or not math.isfinite(contact_height)
or not 0.1 <= contact_height <= 1.0
or not all(np.isfinite(v).all() for v in (points, pose, goal))
or abs(float(np.linalg.norm(pose[3:])) - 1) > 0.01
):
raise ValueError("invalid range/odometry contract")
header = Header(stamp=self.get_clock().now().to_msg(), frame_id="map")
identity = stamp_ns(header.stamp)
with self.condition:
self.support_poses[identity] = (pose.copy(), contact_height)
while len(self.support_poses) > 8:
self.support_poses.popitem(last=False)
odom = Odometry(header=header, child_frame_id="vehicle")
odom.pose.pose.position.x, odom.pose.pose.position.y, odom.pose.pose.position.z = map(
float, pose[:3]
)
q = odom.pose.pose.orientation
q.x, q.y, q.z, q.w = map(float, pose[3:])
target = PointStamped(header=header)
target.point.x, target.point.y, target.point.z = map(float, goal)
fields = [
PointField(name=n, offset=i * 4, datatype=PointField.FLOAT32, count=1)
for i, n in enumerate(("x", "y", "z", "intensity"))
]
cloud = point_cloud2.create_cloud(
header, fields, np.column_stack((points, np.zeros(len(points), np.float32)))
)
# The single HTTP writer establishes one observation transaction.
self.goal.publish(target)
self.speed.publish(Float32(data=speed))
self.odom.publish(odom)
self.scan.publish(cloud)
with self.condition:
fresh = self.condition.wait_for(
lambda: (
self.path is not None
and abs(self.path[0] - identity) <= 1000
and self.command is not None
and abs(self.command[0] - identity) <= 1000
and self.command[1] >= self.path[1]
and self.terrain is not None
and abs(self.terrain[0] - identity) <= 1000
),
# R26's accumulated 26k-point map needs ~0.33 s. Returning at
# 0.3 s perpetually abandons each matching observation just
# before its terrain/path arrives. Wait for that transaction,
# bounded below the independent 0.8 s camera deadman. A late
# result is still rejected by LatestInference, never reused.
timeout=0.6,
)
if not fresh:
return {
"speed_mps": 0.0,
"yaw_rate_rps": 0.0,
"status": "waiting-for-plan",
"path": [],
"pending": {
"path_stamp_delta_ns": None
if self.path is None
else self.path[0] - identity,
"command_stamp_delta_ns": None
if self.command is None
else self.command[0] - identity,
"terrain_stamp_delta_ns": None
if self.terrain is None
else self.terrain[0] - identity,
"path_points": None if self.path is None else len(self.path[2]),
"terrain_processing_ms": self.terrain_processing_ms,
"terrain_points": None if self.terrain is None else self.terrain[1],
},
**(
{"observed_terrain": self.terrain[2].tolist()}
if value.get("include_terrain") is True and self.terrain is not None
else {}
),
}
path, command = self.path, self.command
valid = len(path[2]) > 1 and all(math.isfinite(v) for v in command[2:])
velocity = max(-speed, min(speed, command[2]))
# Reverse is admitted only by the composed recovery policy after
# observing full-width support. Bound heading changes to that strip.
direction_clear = (
velocity <= 0 and abs(command[3]) <= 0.15 if reverse else velocity >= 0
)
velocity, yaw_rate, command_scale = (
regulate_command(velocity, command[3], self.terrain[2], pose)
if valid and direction_clear
else (0.0, 0.0, 0.0)
)
footprint_clear = command_scale > 0
valid = valid and footprint_clear and direction_clear
qx, qy, qz, qw = pose[3:]
tilt = math.degrees(math.acos(max(-1, min(1, 1 - 2 * (qx * qx + qy * qy)))))
failure = (
"inclination"
if tilt >= 30
else "no-path"
if len(path[2]) <= 1
else "footprint"
if not footprint_clear
else "direction"
if not direction_clear
else "controller-hold"
if abs(command[2]) + abs(command[3]) < 1e-5
else "none"
)
obstacles = self.terrain[2][self.terrain[2][:, 3] > MAX_STEP_M]
distances = np.linalg.norm(obstacles[:, :2] - pose[:2], axis=1)
near = obstacles[np.argsort(distances)[:12]]
return {
"speed_mps": velocity if valid else 0.0,
"yaw_rate_rps": max(-0.8, min(0.8, yaw_rate)) if valid else 0.0,
"status": "path" if valid else "blocked",
"path": path[2][::3],
"terrain_points": self.terrain[1],
"footprint_clear": bool(footprint_clear),
"path_frame": "vehicle-yaw",
"diagnostic": {
"failure": failure,
"tilt_degrees": tilt,
"controller_command": list(command[2:]),
"near_obstacles": near.tolist(),
"slope_corrected_points": self.slope_corrected,
"underbody_support_points": self.underbody_corrected,
"terrain_processing_ms": self.terrain_processing_ms,
"command_scale": command_scale,
},
# Engineering replay only; this local endpoint never forwards
# dense geometry to the operator or changes the control input.
**(
{"observed_terrain": self.terrain[2].tolist()}
if value.get("include_terrain") is True
else {}
),
}
def main():
# All ROS traffic stays inside this container. Avoid persistent Fast DDS
# shared-memory segments across causal node resets on Docker/WSL.
os.environ["FASTRTPS_DEFAULT_PROFILES_FILE"] = str(Path(__file__).with_name("fastdds.xml"))
rclpy.init()
node = Navigation()
thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True)
thread.start()
class Handler(BaseHTTPRequestHandler):
def reply(self, status, value):
body = json.dumps(value, allow_nan=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.reply(
200 if self.path == "/ready" and node.ready() else 503, {"ready": node.ready()}
)
def do_POST(self):
try:
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= 3_000_000:
raise ValueError("bounded JSON body required")
value = json.loads(self.rfile.read(size))
if self.path == "/reset":
node.stop_nodes()
self.reply(200, {"reset": True})
# Reset DDS publishers/subscribers as well as child nodes.
# Replacing PID 1 preserves container ownership and clears
# all cached graph/history state before the next observation.
os.execv(sys.executable, [sys.executable, str(Path(__file__).resolve())])
elif self.path == "/plan":
self.reply(200, node.plan(value))
else:
self.reply(404, {"error": "unknown endpoint"})
except (ValueError, KeyError, TypeError) as exc:
self.reply(400, {"error": str(exc)})
except Exception as exc:
self.reply(503, {"error": str(exc)})
def log_message(self, *_):
pass
try:
HTTPServer(("0.0.0.0", 8010), Handler).serve_forever()
finally:
node.stop_nodes()
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
// Exact close-pair hazard and connectedness test; same predicates as NumPy.
// C ABI keeps this numerical hot loop independent of ROS and Python versions.
#include <cmath>
#include <vector>
template<class T> int connected_grade(const T* p, int n) {
if (n <= 0) return 0;
std::vector<int> parent(n);
for(int i=0;i<n;i++) parent[i]=i;
auto root=[&](int i) { while(parent[i]!=i) {parent[i]=parent[parent[i]];i=parent[i];} return i; };
for(int i=0;i<n;i++) for(int j=i+1;j<n;j++) {
const T dx=p[3*i]-p[3*j], dy=p[3*i+1]-p[3*j+1];
const T distance=std::sqrt(dx*dx+dy*dy);
if(distance<=T(0.12)) {
if(std::abs(p[3*i+2]-p[3*j+2])>T(0.1001)) return 0;
parent[root(i)]=root(j);
}
}
const int first=root(0);
for(int i=1;i<n;i++) if(root(i)!=first) return 0;
return 1;
}
extern "C" int terrain_connected_f32(const float* p,int n) {return connected_grade(p,n);}
extern "C" int terrain_connected_f64(const double* p,int n) {return connected_grade(p,n);}
@@ -0,0 +1,170 @@
"""Keep CMU height hazards, except an observed supported grade.
Height above a cell's low quantile is not step height. A smooth 15-degree ramp
can exceed 10 cm across that cell. A 6 cm voxel mesh also quantizes a continuous
grade. Admit that surface only with broad support, a bounded plane residual and
no observed short-range height jump exceeding the qualified 10 cm step. Vertical
surfaces, excessive roughness and sparse/unknown support retain the CMU cost.
"""
import ctypes
import hashlib
import math
from collections import OrderedDict
from pathlib import Path
import numpy as np
_NATIVE_PATH = Path("/opt/missioncore/libterrain_connectivity.so")
_NATIVE = ctypes.CDLL(str(_NATIVE_PATH)) if _NATIVE_PATH.is_file() else None
if _NATIVE is not None:
for suffix, dtype in (("f32", ctypes.c_float), ("f64", ctypes.c_double)):
function = getattr(_NATIVE, "terrain_connected_" + suffix)
function.argtypes = [ctypes.POINTER(dtype), ctypes.c_int]
function.restype = ctypes.c_int
def connected_grade(nearby):
if _NATIVE is not None and nearby.dtype in (np.dtype("float32"), np.dtype("float64")):
points = np.ascontiguousarray(nearby[:, :3])
dtype, suffix = (
(ctypes.c_float, "f32") if points.dtype.itemsize == 4 else (ctypes.c_double, "f64")
)
return bool(
getattr(_NATIVE, "terrain_connected_" + suffix)(
points.ctypes.data_as(ctypes.POINTER(dtype)), len(points)
)
)
# Reference implementation retained for portable CPU tests and comparison.
dx = nearby[:, None, 0] - nearby[None, :, 0]
dy = nearby[:, None, 1] - nearby[None, :, 1]
separation = np.sqrt(dx * dx + dy * dy)
jump = np.abs(nearby[:, None, 2] - nearby[None, :, 2])
if np.any((separation <= 0.12) & (jump > 0.1001)):
return False
connected = separation <= 0.12
reached = connected[np.argmin(np.linalg.norm(nearby[:, :2], axis=1))].copy()
while True:
expanded = np.any(connected[reached], axis=0)
if np.array_equal(expanded, reached):
return bool(reached.all())
reached = expanded
def underbody_support_costs(terrain, pose, contact_height_m=0.37):
"""Reconcile low returns already inside the current chassis footprint.
CMU's neighbourhood ground reference can label the supported terrain under
the chassis as a body collision. Use measured pose and the declared contact
height only inside the body (with a 5 cm inset), never for terrain ahead.
Retain drops, high returns and excessive tilt. The 8 cm band has 2 cm reserve
below the physically qualified 10 cm step; raw terrain memory is unchanged.
"""
result = terrain.copy()
x, y, z, w = pose[3:]
up = np.array([2 * (x * z + w * y), 2 * (y * z - w * x), 1 - 2 * (x * x + y * y)])
if up[2] < math.cos(math.radians(25)):
return result, 0
yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
axes = np.array([[math.cos(yaw), -math.sin(yaw)], [math.sin(yaw), math.cos(yaw)]])
local = (terrain[:, :2] - pose[:2]) @ axes
height = (terrain[:, :3] - pose[:3]) @ up + contact_height_m
supported = (
(np.abs(local) < 0.45).all(axis=1) & (np.abs(height) <= 0.08) & (terrain[:, 3] > 0.1)
)
result[supported, 3] = np.abs(height[supported])
return result, int(supported.sum())
def _neighborhoods(terrain, radius=0.4):
"""Exact radius neighborhoods without scanning the whole accumulated map.
Returns in the nine adjacent cells include every possible neighbor. Keep
source order so fitting and thresholds remain identical to the full scan.
"""
cells = np.floor(terrain[:, :2] / radius).astype(np.int64)
buckets = {}
for index, (x, y) in enumerate(cells):
buckets.setdefault((x, y), []).append(index)
cached = {}
def around(index):
key = tuple(cells[index])
if key not in cached:
x, y = key
cached[key] = np.array(
sorted(
i
for dx in (-1, 0, 1)
for dy in (-1, 0, 1)
for i in buckets.get((x + dx, y + dy), ())
),
dtype=np.int64,
)
delta = terrain[cached[key], :3] - terrain[index, :3]
return delta[np.linalg.norm(delta[:, :2], axis=1) <= radius]
return around
def supported_slope_costs(terrain, cache=None):
result = terrain.copy()
if len(terrain) < 8:
return result, 0
around = _neighborhoods(terrain)
corrected = 0
for index in np.flatnonzero(terrain[:, 3] > 0.1):
nearby = around(index)
if len(nearby) < 8:
continue
# Any admitted plane spans at most a 0.8 m diameter at 25 degrees,
# plus the two 7.5 cm residuals. Reject tall foliage/walls before fitting.
if np.ptp(nearby[:, 2]) > 0.8 * math.tan(math.radians(25 + 1e-4)) + 0.15:
continue
key = None
if cache is not None:
key = hashlib.blake2b(nearby.tobytes(), digest_size=24).digest()
if key in cache:
if cache[key]:
result[index, 3] = 0.0
corrected += 1
cache.move_to_end(key)
continue
cache[key] = False
if len(cache) > 16384:
cache.popitem(last=False)
# No collinear strip, hidden region, multiple height layers or vertical
# surface is admitted as a plane. All observed points must agree.
covariance = np.cov(nearby[:, :2], rowvar=False)
if np.linalg.eigvalsh(covariance)[0] < 0.0036:
continue
matrix = np.column_stack((nearby[:, :2], np.ones(len(nearby))))
plane = np.linalg.lstsq(matrix, nearby[:, 2], rcond=None)[0]
slope = math.degrees(math.atan(np.linalg.norm(plane[:2])))
if not 2 <= slope <= 25 + 1e-4 or abs(plane[2]) > 0.05:
continue
if np.max(np.abs(matrix @ plane - nearby[:, 2])) > 0.075:
continue
# A permissive fit alone could erase a real ledge. Test close measured
# returns explicitly: even a narrow step/drop must retain its hazard.
if not connected_grade(nearby):
continue # Do not fit a road across a gap with no returns.
result[index, 3] = 0.0
corrected += 1
if cache is not None:
cache[key] = True
return result, corrected
class TerrainCostNormalizer:
"""Reuse fits only for byte-identical observed neighborhoods, bounded in RAM.
New or changed returns always trigger a new fit. This stores no occupancy
belief and clears with the owning ROS node on every episode/reset.
"""
def __init__(self):
self.cache = OrderedDict()
def __call__(self, terrain):
return supported_slope_costs(terrain, self.cache)