Nav2 route costs: a penalty does not close an edge

When a Nav2 route avoids an edge, do not begin by treating every large cost as a closure. First ask whether the edge was eligible, which scoring branch ran, and what each scorer contributed. In the Route Server source examined here, an explicit blocked-ID check, a fixed stored-cost branch and a scorer’s boolean result can each prevent the configured dynamic scorer from deciding the case. A numeric contribution has a different role: it changes the accumulated score of an edge that remains eligible.

That distinction explains a common debugging surprise. An expensive edge can still win when every eligible alternative costs more or no cheaper eligible alternative remains. Conversely, a scorer that returns false removes the traversal from consideration in the examined consumer; assigning a merely large positive number does not express the same decision.

This article examines Nav2 tag 1.5.2, commit 7b9be248a652224a19305469fd5e854d50512341, checked on October 6, 2026. It describes the pinned source, not the version installed in a particular robot or every Nav2 distribution.

Trace eligibility before comparing numbers

The useful diagnostic is a branch trace, not a single final cost. In the examined RoutePlanner source, the planner first rejects an explicitly blocked edge or an edge whose destination node is blocked. That test happens before the later scoring alternatives.

If the edge survives that gate, a non-overridable edge or an empty scorer list takes a fixed-cost path. This branch requires the edge’s stored cost to be positive and does not run the configured scorer pipeline for that traversal. It therefore also bypasses DynamicEdgesScorer. A closed ID maintained by that plugin is not a universal closure mechanism for an edge that takes the fixed-cost branch.

Only the remaining branch asks the configured EdgeScorer for a boolean result and a numeric total. These are separate outputs with separate meanings:

Decision point What to inspect What it establishes in the examined source
Explicit blocked IDs Edge ID and destination-node ID supplied to this planning call Either match rejects the traversal before the later branches
Fixed-cost bypass Edge overridability, scorer-list size and stored edge cost Non-overridable edges or an empty scorer list use the positive stored cost instead of configured plugins
Scorer eligibility Boolean returned by each scorer The first false stops scoring and makes the traversal unavailable to this consumer
Scorer contribution Float added by each scorer that returned true Contributions accumulate into a preference score; a high total does not itself mean closure

The implementation throws for a nonpositive stored cost on the fixed-cost path. That is an invalid-value branch in this source, not evidence that every scorer contribution has a universal nonnegative requirement.

Read the scorer as boolean plus contribution

The EdgeCostFunction interface exposes the distinction directly: score() returns a boolean and writes a numeric cost through a separate reference argument. Debugging only the number can therefore miss the decision that made the number irrelevant.

In the pinned EdgeScorer::score() implementation, the aggregate and per-plugin contribution are reset for each invocation. Every configured plugin’s prepare() is called before the plugin score calls for that invocation. The score calls then run in configured order. A true result adds that plugin’s contribution; the first false returns immediately.

This order gives a practical logging scheme. Record the edge ID, the branch that selected fixed cost or plugins, each plugin name in configured order, its boolean result, its contribution and the running total. Do not describe prepare() as a one-time setup for an entire route: the inspected implementation calls it for every edge-score invocation. That source fact does not by itself say how an external data source is synchronized.

The source default list includes DistanceScorer and DynamicEdgesScorer. The optional PenaltyScorer is not in that default list. Its pinned implementation reads a metadata float, multiplies it by a configured weight and returns true; this file does not establish a physical unit or a general nonnegative rule for all configurations. Treat the plugin list and parameters in the loaded configuration as evidence, rather than assuming the optional scorer is active.

Opening an edge and clearing its adjustment are different operations

The examined DynamicEdgesScorer maintains a closed set and an adjusted-cost map. A close request inserts an ID into the closed set. An open request removes it from that set. Adjusted values are stored separately.

Opening an edge does not clear its adjusted numeric entry in this implementation. On a later plugin score call, a still-closed edge returns false before the adjustment lookup. Once reopened, the edge may be eligible again while its stored adjustment still contributes to its score. If the intended operation was both “reopen” and “remove the adjustment,” verify both states rather than using the open operation as proof of both.

The adjustment is one additive plugin contribution. It does not replace the total produced by the other configured scorers. In the examined planner consumer, a successful traversal contribution joins the accumulated route score and competes with the best score already known for the neighboring node. A large finite contribution can therefore lose to a cheaper alternative, win against a still more expensive alternative, or be the only eligible route. None of those cases turns the number into a boolean refusal.

Diagnose an unexpected route in branch order

Use the same order as the consumer so that a later setting does not distract from an earlier bypass:

  1. Confirm graph connectivity and direction. Trace the requested directed edge chain first. The route-graph crossing guide shows why visible linework does not establish shared node identity or reverse travel.
  2. Check planning-call exclusions. Compare the unexpected edge and its destination node with the explicit blocked-ID inputs supplied to this planning call.
  3. Identify the active cost path. Record whether the edge is overridable and whether the configured scorer list is empty. If either fixed-cost condition applies, inspect the stored edge cost instead of attributing the decision to DynamicEdgesScorer.
  4. Inspect plugin order and booleans. For the plugin path, identify the first scorer that returned false. No later contribution will explain an edge rejected earlier.
  5. Inspect contributions and the running total. A positive adjustment is a preference signal among eligible choices. Compare complete route totals, not one edge’s adjustment in isolation.
  6. Check dynamic state separately. Verify membership in the closed set and the adjusted-cost map as two states. Reopening alone does not prove that the numeric adjustment was removed.

These checks explain the source branches. They do not verify a robot’s loaded parameters, service calls, plugin state or observed route. Capture those values from the actual deployment before concluding which branch ran there.

What the measured two-branch comparison shows

One Blender 5.2.2 capture measured eight finite prisms: four node markers and four directed edge markers, with 64 evaluated vertices in total. The recovered centerlines formed two S-to-G branches. The upper S→A→G branch measured 1.9999999 m; the lower S→B→G branch measured 3.2249029 m. The small differences from the nominal 2.000 m and 3.2249031 m values are the measured mesh result, not extra route cost.

The geometry was measured once. Five route cases were then evaluated in a separately authored, dimensionless model. Its base contribution is measured centerline length divided by an explicitly authored 1 m reference, followed by the listed scalar adjustment. Those scalars have no claimed physical unit and are not Nav2 DistanceScorer, PenaltyScorer or DynamicEdgesScorer outputs.

Authored case Eligible measured-path scores Model decision
Baseline Upper 2.000; lower 3.225 Upper edges 10→11
Add 2 to upper departure edge 10 Upper 4.000; lower 3.225 Lower edges 20→21; upper remains eligible
Also add 5 to lower departure edge 20 Upper 4.000; lower 8.225 Penalized upper edges 10→11 still win
Exclude upper departure edge 10 Upper ineligible; lower 3.225 Lower edges 20→21
Exclude both departure edges 10 and 20 No eligible S departure No authored route
Four measured node centers S, A, G and B form two directed paths; edges 10 and 11 each measure 1.000 m, and edges 20 and 21 each measure 1.612 m.
Node centers and edge centerlines recovered from actual audited Blender 5.2.2 vertices, October 6, 2026. The upper branch measured 2.000 m and the lower branch 3.225 m. The five decisions use a separate authored dimensionless cost-and-eligibility model; they are not Nav2, ROS, scorer-plugin or planner output. Marker widths are drawing aids, not lane widths, robot envelopes or sensed blockages.

The second and third rows show why “prefer another edge” and “forbid this edge” are different instructions. Adding 2 to edge 10 changed the preference while preserving the upper route as a candidate. When edge 20 also received 5, that still-eligible upper route won despite its own penalty. Boolean exclusion did something a large finite score did not: it removed the affected departure from the authored candidate set.

The standalone reader used Dijkstra on the nominal authored coordinates. It did not consume Blender measurements. The independent audit separately recovered the actual edge centerlines from evaluated mesh vertices, enumerated both simple S-to-G paths, recomputed the five measured-geometry cases and obtained the same route decisions. This agreement checks the illustration and authored model; it is not a Nav2 result.

The prisms are coordinate and edge markers, not robot bodies, lane envelopes or sensed blockages. No Nav2, ROS, scorer plugin, service, Route Server planner, robot, sensor, traffic, collision, hardware or safety behavior was executed or measured. The practical result is narrower: use a scalar when an edge should remain available but less attractive; use the applicable eligibility mechanism when it must be unavailable, then verify which source branch actually consumed that state.

Record the branch and boolean alongside the number: eligibility determines which edges may contribute, and only then does their accumulated cost determine preference.