L2: the traffic simulator inserts the ego, Carla takes its dynamics over (#86) - #305
Draft
yunlishao-vibe wants to merge 15 commits into
Draft
L2: the traffic simulator inserts the ego, Carla takes its dynamics over (#86)#305yunlishao-vibe wants to merge 15 commits into
yunlishao-vibe wants to merge 15 commits into
Conversation
… over
VirCarlaEnv spawns the ego before the co-sim loop and requires EgoSpawnPose, so
Carla is always the one that creates it and TrafficLayer injects the result into
SUMO on a single-edge dummy route. That direction costs a scenario three things:
the ego's entry time has to be duplicated outside the traffic scenario, a
WarmUpUntilEgoEntry warm-up can never end (the ego only enters once the bridge
is served, and during a warm-up it is not), and SUMO's copy of the ego has no
real route -- so it has no next traffic light either, which is what a signal-
aware controller plans on.
The other direction is what the CarMaker/XIL path has always done: the traffic
simulator declares the ego, the external environment takes its dynamics over.
This makes it available to Carla too, chosen by whether EgoSpawnPose is set:
pose given unchanged -- the bridge creates the ego before the loop and SUMO
gets it injected. For an ego with no counterpart in the scenario.
pose absent the bridge waits, and spawns a physics actor at the pose the
traffic simulator reports the first tick the ego id arrives.
Four changes:
* mainVirCarla: spawn + driver wiring factored into one place, callable before
the loop (as today, settling the car with its own ticks) or from inside it
(settling over the loop's ticks -- an out-of-band world.Tick() there would
advance Carla past the feed and desync every mirrored vehicle). Before it
fires the bridge just mirrors traffic; applyEgoActuation, driveEgoFallback
and readEgoState already no-op without an actor.
* TrafficHelper: the inject path ADOPTS an ego SUMO already has. `vehicleExist`
was computed there and never read; without it Vehicle::add throws "already
exists" every step, and since the throw precedes the moveToXY/setSpeed in the
same try block, the Carla ego was never mirrored into SUMO at all.
* SumoSetup.EgoKeepRoute (default 6, today's hardcoded value): the keepRoute
bitmask for that moveToXY. It selects a failure mode for a vehicle SUMO no
longer drives -- bit 1 places it at the exact position and lets it leave the
network (off-road driving, silent degradation), bit 0 pins it to its own route
and makes SUMO raise when it cannot.
* Two guards where the next-TLS cache is seeded, for externally-driven ids:
ego_sumo_route_changed (a reroute nobody asked for -- the mirror caused it)
and ego_sumo_next_tls_lost (had a next signal, now has none). Both are
invisible downstream: the fields simply read as an empty road ahead, and a
controller keeps planning with its signal logic effectively off.
TrafficLayer and VirCarlaEnv both build clean (Release x64). TrafficLayer parses
an ego-entry warm-up config and defers accepting clients as expected; the ego
handover itself needs a Carla box and is exercised by the MLK L2 scenario in
FIXS_Applications.
…rmal run signalLightId goes empty exactly when no cached TLS entry is still ahead of the odometer, and there are only two ways for that to happen. Either the cached list is empty -- the route was replaced by one without signals, which changes routeId, so ego_sumo_route_changed has already fired -- or the vehicle has passed the last signal on its route, which is what every run does at the end. So the guard added nothing the route guard did not already cover, and warned on the ordinary case. A warning that fires when nothing is wrong is worse than no warning: it teaches everyone to ignore the one that matters. Removed, with the reasoning left at the call site so it is not re-added. The route guard stays, and it is the one that catches the failure that matters: a mirrored ego rerouted by moveToXY, whose replacement route has no signals and whose controller therefore plans against an empty corridor without knowing it.
The obvious way for this guard to cry wolf is ordinary looping, so it was worth checking rather than assuming: <route repeat="n"> is expanded when the scenario loads. On the MLK arterial the ego departs holding one routeID with 1470 edges -- the 70-edge corridor 21 times -- so lapping never changes the id and the guard stays quiet on a healthy run. What it will still report is a deliberate reroute, e.g. a rerouting device. For a vehicle whose motion an external simulator owns that is worth saying out loud as well, so the comment states it rather than leaving the message to be read as an assertion of a fault.
Changing a vehicle's route is a normal thing for an application to do -- route guidance is a CAV function and a rerouting device is the scenario's own business -- so raising on "the routeId is different" would mean the first controller that reroutes its ego teaches everyone to ignore this warning. The guard has to separate a decision from a failure, and routeId alone cannot. What it can key on is the signature of the failure. When moveToXY cannot keep the fed pose on the vehicle's route, SUMO replaces that route with one that "consists of that edge only" -- and a single-edge route is what no deliberate reroute produces. It also predicts the damage: one edge holds at most one signal, so a controller planning on this vehicle is about to lose its stop bar. So the guard is now ego_sumo_route_replaced, on a route that came back with <= 1 edge. Also refreshes VehicleId2EdgeList_um on the reroute path, which is a behaviour change beyond the guard and worth saying so: that list is captured when a vehicle is FIRST seen and never updated, and it is read to reconstruct the signal head from the current lane and the next route edge. After any reroute it described a route the vehicle was no longer on, so the fast path could match a stale outgoing edge and return the wrong signal head. The route is fetched on this path for the guard anyway.
The tick where the traffic simulator's ego becomes a physics actor is where this arrangement either works or does not, and 'the ego just sits there' has three different causes that look identical from outside: it was handed no advisory, it was handed one and is not tracking it, or its pose never changes because it is stuck in the world. One line per feed for 5 s prints exactly those three numbers, so the answer is on the console rather than in a log nobody enabled.
Measured on the MLK arterial with the eco controller as the advisory: the physics
ego was handed a positive target every tick for 650 s of simulation and moved
0.54 m. Nothing was broken in the chain -- the advisory reached the bridge, the
driver applied throttle on 98% of feeds, brake on 1.9%, steering was ~0.02 rad,
no mirrored twin existed (0 of 1.23 M pose-log rows), and mirrored traffic came
within 3 m of it on 14 of 3700 ticks. The vehicle simply did not respond.
The reason is that the speed law is a TRACKING law. A controller pulling away
from a stop bar asks for ~0.15 m/s on the first tick, which the proportional
terms turn into 0.25*0.15 + 0.15 = 0.19 throttle -- and this plant does not move
at 0.19. Probed directly at the ego's own spawn point with the same blueprint
(1845 kg):
throttle 0.45 -> 5.7 m in 4 s, 0.34 m/s (a crawl)
throttle 0.75 -> 44.2 m in 4 s, 2.98 m/s (normal)
The response has a knee, so a fixed throttle floor is the wrong instrument: the
right value differs per vehicle, per grade, per surface. An integral on the speed
deficit finds it by construction -- throttle winds up until the vehicle actually
responds and unwinds as soon as it tracks. Clamped to what throttle can express,
and dropped on braking so it cannot fight the brake.
Also, so this class of question is answerable from a log next time rather than by
a fresh CARLA probe: the bridge now reports what the in-Carla driver applied on
the ego's own actuation fields (they are otherwise unused when the driver is
in-process), and DataLogger learned those three field names. "It was told to
move" and "it moved" were previously indistinguishable in every artifact a run
produced.
… take The post-tick ego readback publishes the Carla ego's record to FIXS whenever EgoMode >= 1, without checking EnableExternalControl. That flag is what creates the carlaOwnsId path in TrafficLayer, so with it false the record does not go down the inject path at all -- it falls through to the ordinary application-layer branch, which applies speedDesired as a plain setSpeed on the traffic simulator's ego. The bridge is the highest-numbered client, so its record also wins the per-id merge against the controller that is supposed to be driving. The result is that "own an ego in Carla, but do not feed it back" silently becomes "drive the traffic simulator's ego from Carla" -- the opposite of what the flag says, and invisible unless you diff a trajectory. It is exactly what makes an EnableExternalControl: false control run on an EgoMode: 2 config fail to reproduce its own baseline. The record is still logged when external control is off; the ego's state is worth recording whether or not anyone consumes it.
yunlishao
force-pushed
the
feature/86_l2_ego_sumo_inserted
branch
from
August 14, 2026 16:54
1441598 to
e5b5019
Compare
yunlishao-vibe
marked this pull request as draft
August 17, 2026 13:30
Brings the warm-up serve-ports work this branch's L2 config depends on, and with it 4f051c6 -- the fix for the subset accept loop taking a readiness probe on a DEFERRED port and leaving the real client unaccepted. Both L2 configs run WarmUpUntilEgoEntry with WarmUpServePorts, so that path is on every run; the bundled binary predates the fix and has to be rebuilt.
…me way
TrafficLayer decided it with `EnableExternalControl && id in InterestedIds`
(carlaOwnsId, TrafficHelper.cpp) and this bridge decided it with `egoMode >= 1`.
With EnableExternalControl false the two answered differently, and nothing
arbitrates -- so both acted on their own answer. TrafficLayer kept the ego on
SUMO kinematics and published its pose for rendering; this bridge consumed that
pose once as a spawn seed, then dropped every later one (VirEnvCore skips
egoId_) and free-ran a physics ego with no anchor in either direction.
That is not a degraded co-simulation, it is two simulations sharing a clock:
neither direction is coupled, so SUMO plans its traffic around a car that is
somewhere else. Measured on mlk_eco_driving before this change -- the two egos
tracked to 0.7 m for 30 s, then background vehicle 3.73, routed around a SUMO
ego 2.9 m away, was teleported through the physics body at 10.34 m/s from
1.56 m. The ego took +2.2 m of z and a 186 deg heading swing and sat wedged
10.2 m off-route for the rest of the run, still being advised 5.3 m/s.
Separation ended at 124.8 m.
carlaOwnsEgo now mirrors carlaOwnsId exactly and gates all eleven ownership
sites (deferred and immediate spawn, mirror skip, driver, advisory, readback,
interested-id skip, spectator, destroy). EnableExternalControl false therefore
means what the mlk_eco_driving L2 config always documented it to mean -- the
CONTROL run: no physics ego, no driver, no advisory, egoId_ empty, the core
mirrors the traffic simulator's ego like any other vehicle. L0, whatever
EgoMode says.
Also: observing is not coupling. The interested-id readback built and logged
its record only under EnableExternalControl, so in the control run it saw the
mirrored ego and recorded nothing; and the "ego_sumo" row was nested inside the
owned-ego block. Both now run once per feed in BOTH modes, with only the push
into FIXS still gated. The ego/ego_sumo pair is what makes the file readable --
in the control run the two should agree and a gap means the mirror is broken,
in L2 they separate by the plant -- and identical columns let runs from the two
modes diff directly. (d.speed was also left unset on that path while
speedDesired carried the magnitude.)
Verified, mlk_eco_driving L2 config unchanged at EnableExternalControl: false:
* ego_debug_log vs the L0 run: max|diff| = 0.000000 and rms = 0.000000 on
ego_speed_mps, raw_eco_speed_mps, final_sent_to_FIXS_mps, dist2Stop_ft and
ego_accel_mps2 over the 1800 shared timesteps -- "never" diverges.
* Carla-vs-SUMO ego separation: mean 28.407 -> 0.292 m, median 8.373 ->
0.000 m, max 124.792 -> 3.221 m. The residual max is the documented ~2-tick
staleness at 15.18 m/s (0.2 s * 15.18 = 3.0 m), not drift.
* z tracks over the full 19 m of grade: Carla 204.90..223.96 against SUMO
204.90..223.97, where before the physics ego climbed 2.2 m clear of it.
* the run completes 650 s instead of dying at 75 s.
… s not 0.2 s
The comment on this block claimed the SUMO-view row was "~2 ticks stale". It is
not stale at all: this is a synchronous co-sim and the mirrored ego is teleported
TO the SUMO pose, so it has no dynamics to fall behind with. The rows differ
because they are sampled at different points -- "ego_sumo" is the received
record, "ego" is the actor read back after world.Tick(), and with CarlaTimeStep
below the feed the core places it on an interpolated blend, so it is a point
SUMO never reported.
The magnitude was wrong too, by about 4.5x. Measured over 2097 control-run feeds
(mlk_eco_driving, EnableExternalControl false, CarlaTimeStep 0.05):
median 0.000 m, and 1216 of 2097 ticks below 1 mm
sep ~= 0.0446*v - 0.004, i.e. a fixed 0.045 s offset -- ONE Carla sub-step,
not two feed periods
flat across speed: 0.039 / 0.045 / 0.042 / 0.045 s for the 0.1-2, 2-5, 5-10
and 10-16 m/s bands, and exactly 0.000 below 0.1 m/s
whole-feed shifts make it worse (mean 0.205 at lag 0 -> 0.264 at lag 1),
so the offset is sub-feed and no integer realignment removes it
The lone 3.2 m excursion is a different thing and not a defect: one tick in 2097
where SUMO's ego changed lane. Against its own heading the jump is +0.280 m
along-track -- exactly the 0.290 m it covers at 2.902 m/s -- and +3.199 m
lateral, heading unchanged by 0.003 deg. One lane width, sideways, in one step:
SUMO's default non-sublane lane-change model, which this app selects on purpose
via --lateral-resolution 0 because it is the traffic Example_Results was
produced with. The next tick reads 0.000 again.
No behaviour change; the binary is unaffected. What changes is that the file can
now be read as a check: expected value is 0.045 s * speed with one-lane-width
spikes at lane changes, and anything outside that shape -- bias at zero speed,
growth over time, or a step that does not recover next tick -- is a real mirror
fault. Recorded because the wrong number in this comment is what I reasoned from
instead of measuring.
flushBatch() sent every mirrored vehicle's transform with ApplyBatch, which on
the Carla client is AsyncCall -- handed to the socket, no wait for the server to
apply it. The next statement in the loop is world.Tick(), so the bridge was
racing its own message. When the tick won, the frame rendered every vehicle at
its PREVIOUS pose while the spectator -- placed from lastAppliedPose, the value
we had just commanded -- had already moved on. The next tick applied both, so the
vehicle jumped twice as far to catch up.
MEASURED (mlk_eco_driving on mlk_no_signal, CarlaTimeStep 0.1, RealtimePacing
true, ~450 consecutive rendered frames per run, sampled by a read-only second
client reading the spectator and the followed actor from the SAME snapshot):
sim_t ego_step cam_step offset
308.0424 0.291 0.291 0.000
308.1424 0.000 0.291 0.291 <- vehicle frozen, camera moved on
308.2424 0.582 0.291 0.000 <- vehicle catches up, double step
308.6424 0.000 0.291 0.291
308.7424 0.582 0.291 0.000
The camera held a steady 0.291 m per frame -- the command stream was always
correct and on time. The offset was never a fraction of a step, only 0.000 or
exactly one step, which rules out physics drift and identifies a pose update that
missed its tick.
frozen lagging offset mean/max frame gap
ApplyBatch, camera via own RPC 24.1% 29.0% 0.089 / 3.211 m 100.0 ms sd 4.4
+ ApplyBatchSync 0.0% 4.7% 0.013 / 0.291 m 104.9 ms sd 43.3
+ camera in the same batch 0.0% 0.0% 0.000 / 0.000 m 100.0 ms sd 4.3
THE FIX, two halves of one idea -- everything this tick renders is applied by one
acknowledged call:
1. ApplyBatchSync instead of ApplyBatch. It keeps the batching win that actually
matters (one RPC for ~180 vehicles instead of 180) and gives up only the
round-trip. The server-side work is not extra: those transforms have to be
applied before the tick regardless.
2. The spectator goes into that same batch (new CarlaBackend::queueTransform)
rather than its own SetTransform RPC. A standalone RPC is a separate message
and can be applied in a different tick from the poses it is meant to be
centred on -- that was the 4.7% residual left after half 1. flushBatch() moves
from just after core.runStep() to just before world.Tick() so the camera is in
the batch when it goes out. Nothing in between reads back a Carla transform:
the ego-control calls drive TM / pedals, and the pose log reads lastAppliedPose,
our own copy.
NOT ONLY COSMETIC. The interested-id readback reports actor->GetTransform() back
to FIXS, so with EnableExternalControl a stale actor sent a pose one step old --
and anything differencing successive poses for velocity saw 0 then 2x, a 100%
velocity error on a quarter of samples.
ON COST. Sync adds a round-trip per tick. Measured here it is free: the frame gap
is 100.0 ms with sd 4.3 in the full fix, matching async (100.0, sd 4.4). Half 1
alone showed a worse tail (sd 43.3 ms, one 663 ms stall) which did not survive
moving the flush next to the tick; I have not isolated why, and one 45 s window is
thin evidence for a tail. The two-machine case (#224) is where this needs
re-measuring: the round-trip becomes a network RTT, and the race being fixed also
gets worse with latency, so distributed runs both need this more and pay more.
Physics-driven ego (EgoMode >= 1) is untouched: its pose is produced BY the tick,
so it keeps the post-tick snap.
(cherry picked from commit 3f08d94)
The comment on this block has been wrong twice, both times because it was
reasoned from rather than measured, and both times in the same direction --
explaining a real defect away as something inherent:
"the ego ~2 ticks stale" there is no lag to be stale by; the mirrored ego
is teleported to the SUMO pose in a synchronous
co-sim, so it has no dynamics of its own
"a 0.045 s sampling offset" 0.0446 was the slope of a fit through a BIMODAL
population, so it described no individual tick
The real cause was the async ApplyBatch in flushBatch() racing world.Tick().
Measured over 2061 moving control-run feeds: 42.75% held the previous feed's
pose EXACTLY (sep/speed 0.1000 s median = one whole feed), 57% were bit-exact,
nothing in between. That 0/0.1 split is a lost race, not an offset -- and the
average of the two populations is what produced the phantom 0.045 s. The lone
3.2 m excursion was the same mechanism: the stale feed happened to be the one
SUMO changed lane in, so the gap was a lane width (+3.199 m lateral, +0.280 m
along-track, heading unchanged by 0.003 deg) instead of 0.1 s of travel.
Fixed by cherry-picking FIXS#267 (1feb381). Verified on a full 650 s control
run, 6430 moving feeds, EnableExternalControl false, CarlaTimeStep 0.1:
before mean 0.2047 median 0.0001 p99 1.4658 max 3.2108 m 42.75% stale
after mean 0.0000 median 0.0000 p99 0.0001 max 0.0003 m 0.00% stale
0.3 mm is float32 on the wire, not motion. The SUMO side is unaffected and still
exact: identical to Example_Results' numeric baseline on all five compared
columns over the full 6501 shared timesteps (29100.1 -> 29750.1), and to the L0
run over their 1800.
The comment now states the expectation as ZERO and says plainly that any nonzero
reading is a mirror fault, so the next person does not spend the afternoon
inventing a third reason why "close" is fine.
…ting a stale odometer Two defects in the same block, both reachable only by an externally-driven ego, which is why a SUMO-driven one never exposed them in years of use. 1. The fallback took nt[0].tlIndex outright. `t.id` is the signal the odometer-indexed cache resolved to; `nt[0].id` is the first signal getNextTLS says is upcoming. They are not always the same signal, and the head was taken anyway -- reporting 202605864 head 9 when the 9 belonged to 202587081, 81 m away. Measured over 5511 fallback ticks: they disagreed on 2249, and the head was rejected by the application on 74.2% of those against 0 of the 3262 where they agreed. Now the entry matching t.id is used, falling back to the seeded t.index rather than a foreign signal's head. The fallback is not the rare path its old comment claimed: 78-88% of ticks in both L0 and L2, because a vehicle is usually not yet on the approach lane of the signal it is heading for -- TlsTopology_um is keyed on controlled-link fromLane, so the fast path can only hit within ~50 m of the junction. 2. An externally-driven id now reseeds every tick. cumDist is `t.dist + odo` frozen at seed time, and `cumDist - odo` is only meaningful while the odometer advances monotonically with distance along the route. Under moveToXY it does not: measured, the ego's odometer FROZE for five ticks and then REGRESSED 17.2 m while the Carla ego was on the road 0.02 m off the lane centreline at 4.5 m/s. Nothing reseeds on that -- the cache is keyed on routeId, which does not change -- so one regression biased every later distance by ~21.7 m (worst -176 m) for the rest of the run. Reseeding makes cumDist - odo collapse to getNextTLS's own live answer, so id, head and distance come from one reading. One extra call per tick for ONE vehicle; the #177 saving across the other ~180 is untouched. Also adds the RS_EGO_ROUTE_LOG probe (opt-in, off by default, no cost when unset): one row per exchange with routeId, edge count, route index, lane, odometer, cache size, which path resolved the head, and getNextTLS's raw answer. Every number quoted above came from it, and it is what turns "the ego behaves oddly" into a table. Verified on mlk_eco_driving L2: head rejection 25.7% -> 0.0%, control == False 35.2% -> 0.9%, commanded-above-limit 54.7% -> 7.0%, ticks with a leader gap under 2 m 121 -> 3.
A scenario can be broken in a way that still produces a full, clean-looking run
of meaningless numbers. mlk_eco_driving's EgoRoutePoints were generated by
applying one lane index to every edge, which put the ego in lanes unreachable
from its own route -- E1 reaches 51066109#3_1 and _2, never _0, which is fed
only by a side approach. SUMO could not place it coherently: empty lane id,
odometer frozen then rewound 17 m, the next-signal cache drifted with it, the
application stopped recognising the movement and released the ego to free-flow
speed into the car ahead. For 650 s, with no error raised anywhere.
check_ego_route.py maps each point to the lanes within tolerance, then walks the
sequence carrying the SET of lanes the ego could consistently be on; a step with
no consistent assignment is a violation. Two details it has to get right or it
cries wolf:
* connections report only their destination lane, so the junction interior --
the separate `via` -- must count as a legal step, or every junction looks
illegal;
* a sample must not be committed to its nearest lane. Junction interiors
overlap, so the nearest lane is often a neighbouring movement's.
It also allows a few hops between samples, because the stored polyline is
decimated to ~30 m and short lanes in between are never sampled.
doctor.py gains a Scenario tier that runs it against the selected profile, and
reports SKIPPED rather than passing silently when nothing is selected -- a check
nobody can see not running is how the last one of these went unnoticed.
On the route that caused all of the above: 5 illegal steps, exit 1. On the
regenerated one: 169 points, every step a real connection.
_peek_any_endpoint promises (None, None) when it cannot read, and guards with `except Exception`. But read_carla_endpoint sys.exit()s when PyYAML is missing -- correctly, for a caller that needs the config, since substituting defaults there invents settings and then drives real decisions with them. SystemExit derives from BaseException, so it walked straight through the guard and took the process down. That broke the documented bootstrap contract. run_cosim.bat starts under any python on PATH and re-execs under the configured one, so the bootstrap is meant to need nothing beyond the stdlib -- but --doctor reaches this peek BEFORE the re-exec. On a machine whose PATH python has no PyYAML, `run_cosim.bat --doctor` died on the spot while the configured interpreter had PyYAML all along. Also wires the Scenario tier in: _peek_scenario resolves --profile, else the saved 'last', to a (config, net) pair for doctor. Same source of truth as a real run, so `run_cosim.bat --doctor` with no arguments checks the scenario you would actually launch. Verified through run_cosim.bat itself, under the PyYAML-less bootstrap.
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.
The ego half of the L2 work. The warm-up change that was previously on this branch has been split out into #307, which is independent and can land first.
1. The traffic simulator can insert the ego
VirCarlaEnvspawns the ego before the co-sim loop and requiresEgoSpawnPose, so Carla is always the creator and TrafficLayer injects the result into SUMO on a single-edge dummy route. This adds the other direction — the one the CarMaker/XIL path has always used — selected by whetherEgoSpawnPoseis set, so nothing existing changes:EgoSpawnPoseWith Carla as creator, the ego's entry time has to be duplicated outside the traffic scenario, and SUMO's copy of the ego gets a single-edge route — so no next traffic light, which is the only stop-bar input a signal-aware controller has.
mainVirCarla.cpp— spawn + driver wiring factored into one place, callable before the loop or inside it. The deferred path deliberately does not tick: an out-of-bandworld.Tick()there advances Carla past the feed and desyncs every mirrored vehicle.TrafficHelper.cpp— the inject path adopts an ego SUMO already has.vehicleExistwas computed at that site and never read; without itVehicle::addthrows "already exists" every step, and since the throw precedes themoveToXY/setSpeedin the sametry, the Carla ego was never mirrored at all.2. Do not feed the traffic simulator an ego it was told not to take
The post-tick readback published the Carla ego's record whenever
EgoMode >= 1, without checkingEnableExternalControl. With that flag false there is nocarlaOwnsIdpath to consume it, so it fell through to the ordinary application-layer branch and was applied as a plainsetSpeedon the traffic simulator's ego — from the highest-numbered client, so it also beat the controller that was supposed to be driving."Own an ego in Carla but do not feed it back" silently became "drive the traffic simulator's ego from Carla". It is what makes an
EnableExternalControl: falsecontrol run fail to reproduce its own baseline, and it is invisible unless you diff a trajectory.3.
SumoSetup.EgoKeepRouteand a route-replacement guardEgoKeepRoute(default6, the previously hardcoded value) exposes thekeepRoutebitmask for the mirror'smoveToXY— really a choice of failure mode for a vehicle SUMO no longer drives: bit 1 places it at the exact position and lets it leave the network (off-road driving possible, degradation silent); bit 0 pins it to its own route and makes SUMO raise instead.ego_sumo_route_replacedfires when the route was replaced by the mirror, which is not the same event as a route change. Changing a route is a normal application action — route guidance is a CAV function — so raising on "the routeId differs" would teach users to ignore it. SUMO's replacement "consists of that edge only", which no deliberate reroute produces. Ordinary looping cannot reach it either:<route repeat="n">is expanded at load (measured: one routeId holding 1470 edges on the MLK arterial).4. The ego driver needs an integral term
The speed law is a tracking law, so a controller pulling away from a stop bar asking ~0.15 m/s gets
0.25*0.15 + 0.15 = 0.19throttle. Probed at the ego's own spawn with the same blueprint (1845 kg): 0.45 throttle → 5.7 m in 4 s (0.34 m/s); 0.75 → 44.2 m (2.98 m/s). The response has a knee, so a fixed floor is the wrong instrument — the right value differs per vehicle, grade and surface. Integrating the deficit finds it. Effect on the MLK L2 run: 0.54 m travelled → 5513 m.5. Observability
The bridge reports what the in-Carla driver applied on the ego's own actuation fields,
DataLoggerlearned those three names, and the handover prints a 5 s trace. "It was told to move" and "it moved" were previously indistinguishable in every artifact a run produced, which is most of why the first stall took a day to find.Testing
EnableExternalControl: true, the Carla ego and SUMO's ego agree to a median 0.02 m, p95 0.03 m over 6501 feeds, and the ego keeps its next traffic signal on 100% of controlled ticks.Known / not settled
EnableExternalControl: falseandEgoMode >= 1, the Carla ego is invisible to SUMO, so mirrored traffic — teleported, hence immovable to PhysX — drives through it and ejects it; in one run it was thrown off the corridor and fell to z = −10 km. A control run should useEgoMode: 0. Whether the injected case needs a collision-level fix as well is open.Consumer: ORNL-Real-Sim/FIXS_Applications#41.