Conversation
Ports meta_ws's refine-localization-in-place capability to hangar_sim, plus the
manual-localization step it sits on top of. Tracked as moveit_pro#22542.
New package hangar_sim_behaviors:
- CallEmptyService: a generic std_srvs/Empty service caller, following
CallTriggerService's shape (ServiceClientBehaviorBase, service_name port).
Core only handles std_srvs::srv::Trigger, and the two types are unrelated, so
no configuration of CallTriggerService reaches an Empty service. beluga_amcl
alone advertises two: /request_nomotion_update and
/reinitialize_global_localization.
- ScanMatchResidual: the fit-to-map gate. Reads beluga's own likelihood field one
step before beluga turns distances into likelihoods and reports the fraction of
the beams the filter would have scored whose endpoints land within a tolerance
of an occupied cell. Covariance cannot be the acceptance test: it falls as the
particle set depletes, so it shrinks whether the surviving pose is right or
wrong.
- SeedLocalizationAtPose: seeds the filter from an arbitrary pose. Core's
SetInitialPose seeds from TF -- from the estimate the filter already holds --
so it can tighten a cloud but never move it, and cannot restore an operator's
click after a rejected refinement.
localization_gates.{hpp,cpp} holds the ROS-free geometry, mirroring beluga rather
than nav2_amcl in the three places the two differ and it matters: beam decimation
is take_evenly, not an integer index step; cell lookup floors in the grid frame
rather than rounding to the nearest centre (a systematic half-cell, 0.025 m here);
and a cell is an obstacle at exactly 100, not at >= occupied_thresh. Each
difference is pinned by a unit test against the nav2 rule it is not.
test_localization_gates.cpp also holds one failing-by-design case: a perfectly
periodic corridor, where a pose one bay along scores exactly as well as the true
one. That is a real limit of any fit-to-map test and the reason the refinement is
click-seeded and drift-bounded rather than a search.
calibrate_scan_match_gate + dump_localization_calibration_data.py re-measure the
threshold against a specific map. The dump captures the grid map_server actually
published rather than re-deriving it from the .pgm with a second loader, which
removes the cell-for-cell equivalence question instead of answering it.
The threshold in this commit is still meta_ws's 0.80 and has NOT yet been
measured against hangar_map. Calibration and the against-ground-truth
verification both need exclusive sim access, which is not available yet.
Objectives (shape is provisional -- see below):
- Localize Robot: click, transform the click out of the planning frame into map,
seed. Ported from meta_ws's localize_robot.xml.
- Refine Localization In Place: a non-runnable subtree that seeds, runs a bounded
loop of forced no-motion updates, gates the result on drift and on fit to the
map, and either keeps the refined pose or restores the click.
- Localize Robot and Refine: the two composed, for an operator.
amcl now runs with set_initial_pose: false, because a robot does not power on at
a known pose. That has a consequence specific to this workspace: AMCL's map ->
odom is the only link between the MuJoCo scene frames and MoveIt's planning
frame, so with the filter unseeded every point-cloud Objective fails its
transform, not just the navigation ones. The integration test therefore seeds the
filter once in a fixture, from the spawn pose recorded in nav2_params.yaml.
Not verified in this commit: the package builds except for one quaternion
construction fixed after the last successful compile, and no test has been run
since. Builds are on hold while a timing-sensitive measurement has the host.
Applies the captain's decision on objective shape. The refinement is a reusable subtree; both entry points reference it rather than copying it. - refine_localization_in_place_subtree.xml: the tree itself, runnable="false". - Localize Robot: click, transform the click into map, then call the subtree. Seeding and refining are one operator action, so a careless click is caught before the robot moves rather than after. - Refine Localization In Place: the bumped-robot case. Seeds from the estimate the filter already holds (map -> ridgeback_base_link) instead of from a click, so it needs no operator input. That also makes it the one runnable Objective in this feature the headless integration suite CAN execute, which is why it is not in skip_objectives: it exercises the forced-update loop and both gates against the seed the localized_robot fixture applied. Replaces localize_robot_and_refine.xml, which was a second copy of the same composition. Favorites: hangar_sim was at 7 of the 8 the favorites check allows, so exactly one of these can be favorited. Localize Robot is the one an operator reaches for.
…ction claim Two fixes found by reading, both in the refinement subtree. 1. The comment on the fit-to-map gate contained a literal "--", which XML forbids inside a comment. The file did not parse. This is the class of defect meta_ws warned about: a subtree that is never instantiated is not verified, and reading the XML does not catch it. All three Objective files now parse. 2. The drift-limit comment claimed hangar_sim's non-zero recovery_alpha_slow/fast mean beluga "MAY inject particles drawn uniformly over the whole map during the loop", and that the seed's ~4 sigma bound was therefore not structural here. Traced through beluga, that overstates it. What is true: beluga's injected states come from MultivariateUniformDistribution<SE2d, OccupancyGrid>, which draws a translation uniformly from grid.free_cells() and a heading from SO2d::sampleUniform -- the whole map, any orientation. If it fired inside the loop, the bound would go. What is also true, and is why it does not: amcl_core applies `normalize` immediately before the recovery estimator, and normalize divides by the total weight, so the estimator's input is exactly 1/N on every update. It measures how the particle COUNT moves, not how well the scan fits. A converging in-place refinement only depletes the set: N falls, 1/N rises, the fast average leads the slow one, and 1 - fast/slow clamps to zero. Injection requires N to grow. The gate stays, because a transient N increase right after a bad seed can still inject. The comment now says which of those two things it is. Separately, and NOT changed here: the same trace implies hangar_sim's recovery alphas are probably not buying the kidnapped-robot recovery they look like they buy, since in beluga they cannot see fit quality at all. That is a config finding for its own ticket. Also verified by reading, against the compiled libraries rather than the docstrings (GetLatestTransform's docstring is stale, so the docstrings are not trustworthy): every Behavior ID used by the three Objectives is registered by a loader hangar_sim configures, and every port name each one passes exists.
calibrate_scan_match_gate defined both worstOverOffsetRing and bestOverOffsetRing but only ever called the second. The workspace compiles with -Werror, so the dead one failed the build with -Werror=unused-function. Keeping the best-case one is the deliberate half: a threshold has to reject the BEST-scoring wrong pose at a given offset, not merely beat the worst one, so the worst-case figure was never the number the calibration wanted.
…tf2 path The package had never been linked. Every earlier build died at compile (-Werror=unused-function) before the executable link, so an undefined tf2::fromMsg(geometry_msgs::msg::Quaternion const&, tf2::Quaternion&) sat in libhangar_sim_behaviors.so unnoticed, and the review round that added planar_pose.hpp and test_planar_pose.cpp never reached a build either. tf2::getYaw resolves through tf2::fromMsg, which is an inline in tf2_geometry_msgs.hpp rather than in tf2/utils.hpp. Whether that links depends on include ORDER -- two-phase lookup needs the definition visible before the template that calls it -- and this repository sorts includes, so the include that fixes it does not stay where it is put. The failure also lands badly: object files compile, the shared library links, and only something linking that library fails, which is why it surfaced on calibrate_scan_match_gate. So the package computes yaw itself. planar_pose.hpp gains yawOf(), two lines of atan2, and scan_match_residual and seed_localization_at_pose use it. Yaw is the only rotation a 2D localizer carries, so this is not a shortcut. test_planar_pose reads yaw back with its own local atan2 rather than yawOf, so a bug in yawOf cannot hide itself in the test that checks the projection. Also disables the CLI's offer to upgrade this worktree to the stock example workspace in the lane-private config: with no terminal on stdin the build hung on that prompt, and answering yes would rewrite the task branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h unseeded map->odom
…n hangar_map Everything here comes from running the simulator. Four defects on this branch had already come from reasoning about it instead, and this round overturned one more. BELUGA DOES NOT PUBLISH map -> odom UNTIL SEEDED. Measured: with set_initial_pose false the node reports lifecycle state ACTIVE, consumes 356 /scan_merged messages, and still emits zero /amcl_pose while map -> odom raises ConnectivityException. Publishing a seed makes the edge appear at once. That is the opposite of what this branch claimed one round ago. beluga's binary carries a "distributed across the map" initialisation string, which reads like it global-initialises and broadcasts from an unconverged cloud; it does not do that here. Both comments that asserted it are corrected, and both now say the claim was observed rather than inferred, because the strings gave the wrong answer. Consequences, all confirmed rather than argued: - The bootstrap deadlock is real, so relabelling the click in the map frame is necessary rather than merely defensible. The relabel commit is restored. - The integration fixture goes back to waiting on can_transform: with no edge at all before the seed, the edge appearing IS evidence the seed was consumed. - An unseeded filter really does split the TF tree, so every point-cloud Objective, not just navigation, depends on this. GATE CALIBRATED ON hangar_map, with the shipped scoring code against the grid map_server actually publishes (1007 x 1231 at 0.05 m, origin -25.1/-2.9): true pose 87.0% pose 0.10 m / 1 deg off 69.6% pose 0.25 m / 3 deg off 30.4% strongest alias on the map 34.8% at (-2.67, 29.93) separation 52.2 points (meta_ws measured ~21 on theirs) min_inlier_fraction is 0.60, not the 0.80 meta_ws ships. 0.80 sits only 7 points under the true pose here AND above the 69.6% a 0.10 m error scores, while the refinement returns about 6.5 cm -- it would reject the refinements the loop legitimately produces. 0.60 still clears the strongest alias by 25 points. Two properties of that measurement travel with it. Only 23 of the 60 selected beams survive the range filters on this robot's merged scan, so the fraction moves in steps of about 4.3 points: the gate is coarse here in a way it was not on meta_ws's denser scan. And every sample came from one stationary pose, so "the true pose" is the only true pose measured. A driven multi-pose campaign is owed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Localize Robot" asked for a click. GetPoseFromUser returns the ray hit for position and, with is_normal, the clicked FACE NORMAL for orientation, which on a floor is not a heading. Heading is the axis this Objective exists to pin down and it cannot be recovered afterwards: a forced no-motion update SELECTS among the particles the seed scattered, so a seed pointing the wrong way stays pointing the wrong way, and the fit-to-map gate then rejects it. Widening yaw_std_dev is not the answer either -- a cloud spread wide in yaw is how a filter converges facing the wrong way. So the pose comes from AdjustPoseWithIMarker, which returns exactly the pose the operator places, heading included. Four things it does, each recorded in meta_ws's navigate_to_imarker_pose.xml: it takes a LIST, so the seed is wrapped in a one-element vector and read back at index 0; it handles exactly one pose; the prompt must contain no semicolon, because the port is a string list that splits on it; and it always returns poses in 'world'. That last one lands in the trap this branch already measured, so this Objective does NOT follow the reference's next step. meta_ws transforms the marker pose into map with TransformPoseFrame, and is right to: that Objective sets a NAVIGATION goal, by which point the robot is localized and map -> odom exists and means something. This one SEEDS, at cold boot, where that edge does not exist at all -- so the pose is relabelled through ReinterpretPoseFrame instead. Same conversion, opposite precondition. What the operator sees is NOT settled. AdjustPose.srv carries only prompts and initial_poses, with no gripper, mesh or end-effector field, so the Objective cannot influence what is drawn and the rendering is entirely UI-side. The MoveIt Pro UI is the separate Desktop App -- the runtime's 3200 endpoint is an API, not a web app -- so it cannot be observed headlessly. The evidence that it is usable for a base pose is that meta_ws ships exactly this Behavior for a base goal on mike/WIP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…verride; widen alias sweep; correct localization comments
…as table and gate docs
…nd and coverage claims
…pic; harden yaw test
…s, in failure messages
…tion hop; sync amcl comment
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
Removes calibrate_scan_match_gate and the calibration_io pair it was the only consumer of, with their CMakeLists entries. Verified before removing: nothing else in the workspace includes calibration_io, and nothing else builds or runs the tool. The package still builds and its unit tests still pass. C++ in this package goes from 3115 to 2345 lines. Removed at the captain's request to keep the change tight. WHAT THIS DOES NOT REMOVE: everything the tool produced. The measured hangar_map table, min_inlier_fraction 0.60, the 52.2%-87.0% band, the 17-point accept/reject window, the 23-of-60 beam granularity and the one-stationary-pose caveat are findings, not tooling, and they stay in localization_gates.hpp and on the subtree's port documentation. RECOVERABILITY. The tool is not rewritten from scratch when the driven multi-pose calibration campaign happens -- it is restored from this branch's own history at cffa2b1 That sha is named in localization_gates.hpp, in the dump script's docstring and in the PR body, so whoever picks the campaign up finds it without having to dig through the log. dump_localization_calibration_data.py stays: it is Python, not part of the C++ volume being cut, and it writes the two files the restored tool consumes. Its docstring now says where the tool went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Author
|
Update: need to re-arch this to put commn nav behaviors in the nav folder in moveit pro to support these new objectives. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports
meta_ws's refine-localization-in-place capability tohangar_sim, plus the manual-localization step it sits on top of. Tracks PickNikRobotics/moveit_pro#22542 (that repo has issues disabled, so the work lands here).The problem
After an operator sets the robot's pose on the map, the estimate stays exactly as good as that input until someone drives the base — AMCL will not update a filter whose base has not moved past
update_min_d/update_min_a.Measured result, against MuJoCo ground truth
Seeded deliberately off by
dx=0.35 m, dy=-0.25 m, dyaw=8°(xy_std_dev0.5):~7× better in position, ~90× in heading — in line with
meta_ws's 4–5 cm / <1°./request_nomotion_updatewas confirmed present and responding onbeluga_amcl, closing an open question in themeta_wsreport.The cold-boot observation that overturned earlier reasoning
An earlier round of this branch asserted, from strings and symbols in the beluga binary, that beluga global-initialises on map receipt and broadcasts
map→odomfrom an unconverged cloud. Running it showed the opposite. Withset_initial_pose: false, beluga reports lifecycleACTIVE, consumes 356/scan_mergedmessages, and publishes zero/amcl_posewhilemap→odomraisesConnectivityException. Seeding makes the edge appear at once.The
"distributed across the map"initialisation string does exist in the binary, but that path is not taken with this configuration. Reading the strings gave the opposite answer to running it.Consequences, all confirmed rather than argued: the bootstrap deadlock is real, so relabelling the pose into the map frame is necessary rather than merely defensible; the integration fixture waits on
can_transformbecause with no edge before the seed, the edge appearing is evidence the seed was consumed; and an unseeded filter genuinely splits the TF tree, so every point-cloud Objective depends on this, not just navigation.Gate calibrated on
hangar_map—min_inlier_fraction0.60Measured with the shipped scoring code against the grid
map_serveractually publishes (1007×1231 @ 0.05 m, origin −25.1/−2.9):The squeeze: the gate must accept a 0.10 m near-miss at 69.6% and reject a 0.25 m wrong pose at 52.2% — a 17-point window, which 0.60 splits. Higher starts rejecting refinements the loop legitimately returns; lower starts admitting poses it must reject. That window is narrow on
hangar_sim, narrower thanmeta_ws's.A structural blind spot worth knowing: a 0.10 m pure translation with correct yaw scores 87.0% — identical to the true pose — because it is inside the 0.15 m
inlier_distance. The gate cannot detect an error smaller thaninlier_distance, and the refinement returns ~6.5 cm, inside that blind spot. What makes those centimetres trustworthy is the seed's density, not the gate.The numbers moved twice, and a reader should see the correction: the first sweep used
alias_keepout_m = 2.0 mand reported the strongest alias at 34.8% with a 52.2-point separation — but it skipped every candidate closer than 2.0 m. Dropping the keepout to the 1.2 m drift limit found a stronger alias at 47.8%, sitting 1.28 m from truth inside the annulus the first sweep skipped. Folding in offset rings inside the accept region then showed the real constraint is not the alias at all, but the 0.25 m must-reject ring. Both revisions made the margin smaller and more honest.Caveat, prominently: every sample came from one stationary pose, so "the true pose" is the only true pose measured. A driven multi-pose campaign is owed, and
dump_localization_calibration_data.pymust be run stationary or given stamp synchronisation first — a scan stale relative to the recorded truth depresses the true-pose score and biases the threshold downward. Only 23 of 60 selected beams survive the range filters on this robot's merged scan, so the fraction moves in ~4.3-point steps: the gate is coarse here in a way it was not onmeta_ws's denser scan.The marker
Localize RobotusesAdjustPoseWithIMarker, not a click.GetPoseFromUserreturns the ray hit for position and, withis_normal, the clicked face normal for orientation — which on a floor is not a heading. Heading cannot be recovered afterwards: a forced no-motion update selects among the particles the seed scattered, so a seed pointing the wrong way stays pointing the wrong way; and wideningyaw_std_devis how a filter converges facing the wrong way.Not a contradiction of
meta_ws'snavigate_to_imarker_pose.xml. That Objective converts the marker pose tomapwithTransformPoseFrameand is correct to: it sets a navigation goal, where the robot is localized andmap→odomexists. This one seeds, at cold boot, where that edge does not exist — so it relabels withReinterpretPoseFrame. Same conversion, opposite precondition.Known limitations and risks
meta_wsassumption that the worst case equals click-only localization. True at cold boot; false when re-running over an already-good estimate. Named as follow-up.SetInitialPose/GetLatestTransformfail on any unavailablemap→ridgeback_base_link, so a never-seeded filter and a transiently unavailable estimate look identical. The second routes an already-localized robot into re-localization, reaching the hazard above from another direction. Messages state the symptom and warn about replacement; distinguishing the cases is follow-up.|map→odom|from 1.2 cm / 0.92° to 6.7 cm / 0.02°.hangar_sim's odometry is MuJoCo ground truth via the virtual-rail joints, so the correction barely grows here. This is not a hardware bound — on dead-reckoned odometry it grows much larger.ScanMatchResidualis aSyncActionNodeblocking up totimeoutfor/mapand again for the scan (5 s each), andSeedLocalizationAtPoseup to 5 s for a subscriber. The objective server holdschange_tree_state_mutex_acrosstickOnce, so a cancel cannot land until the tick returns — the same window that can leave a partially refined, ungated pose.refine_localization_in_place.xml. That contradicts this branch's own argument for extracting the refinement into one subtree referenced twice. Extracting it in a final round would trade a reviewed shape for an unknown one — follow-up.cffa2b176eb73db7587f0c64607202d20ad0335e— see above.kYawDriftLimitis read by nothing;kMinInlierFractiononly by a unit test. The thresholds the gate runs on live in the subtree's input-port defaults, so a re-calibration that changes the XML leaves the header constants stale — follow-up.Not verified
AdjustPose.srvcarries onlypromptsandinitial_poses— no gripper, mesh or end-effector field — so the Objective cannot influence what is drawn; it is entirely UI-side, and the UI is the separate Desktop App. The evidence it is usable for a base pose is thatmeta_wsships this same Behavior for a base goal onmike/WIP.A defect class worth reporting
Review found the same defect in three kinds of location over successive rounds: a failure path confidently naming a cause it has not established. An audit of every operator-facing failure message this branch added found 8 of 12 wrong — the review had caught 2 of those 8. The same false claim then turned up in the
nav2_params.yamlnarrative comment, and finally in the BT node names, which show in the UI tree view and logs. Fixing the message is not the same as fixing the claim.The calibration tool was removed — and where to get it back
calibrate_scan_match_gateand thecalibration_iopair it was the only consumer of are not in this branch. They were removed at the captain's request to keep the change tight; C++ in the package drops from 3115 to 2345 lines. Nothing else includedcalibration_ioand nothing else built or ran the tool, so it came out cleanly and the package still builds with its unit tests passing.Everything the tool produced stays. The measured
hangar_maptable,min_inlier_fraction0.60, the 52.2–87.0% band, the 17-point accept/reject window, the 23-of-60 beam granularity and the one-stationary-pose caveat are findings, not tooling, and they remain inlocalization_gates.hppand on the subtree's port documentation.Restore it, do not rewrite it. The tool lives in this branch's history at
That sha is named in
localization_gates.hpp, indump_localization_calibration_data.py's docstring, and here — so the driven multi-pose calibration campaign starts by recovering a reviewed tool rather than writing a new one.dump_localization_calibration_data.pystays in the branch: it is Python rather than C++ volume, and it writes the two files the restored tool consumes.C++ volume compared to
meta_wsA reviewer will ask, so:
meta_wsshipped this capability in roughly 1,342 lines of C++ (fa11d47onmike/WIP:get_amcl_pose,localization_gates,request_nomotion_update,scan_match_residual,wait_for_one_message, plus a 345-line gate test). This package is 2,345 after the strip.Most of the difference is the frame handling their robot never needed.
meta_wsseeds a filter whosemap→odomalready exists, so a singleTransformPoseFramedoes the job. We seed at cold boot, where that edge does not exist at all — measured, see above — which is why this branch carriesReinterpretPoseFrame(with its expected-input-frame guard),ProjectPoseToPlaneandplanar_pose.hpp, plus the three-branch conditional fallback and itsIsUserAvailablegate in three Objectives. The remainder isCallEmptyService, which is a generic mirror of core'sCallTriggerServiceand is recommended below for promotion out of this package entirely.Where these Behaviors belong — question for the reviewer
Four of the five are correctly local:
ScanMatchResidual,SeedLocalizationAtPose,ProjectPoseToPlaneandReinterpretPoseFrameencode this robot's merged scan, this map's likelihood field or a 2D filter, and their port defaults arehangar_mapcalibration values.CallEmptyServiceis different in kind — no hangar-specific content, a mechanical mirror of core'sCallTriggerServicediffering only in service type — so it is the one that belongs in the shared library.For the record: it does not close moveit_pro#18066.
slam_toolbox/srv/SaveMapisstd_msgs/String namein,uint8 resultout — a filename and a result code, notstd_srvs/Empty. The genuine case for an Empty caller is thatbeluga_amclalone advertises two:/request_nomotion_updateand/reinitialize_global_localization— the second of which must never be used inside a click-bounded refinement, because it scatters particles uniformly over the whole map.🤖 Generated with Claude Code