Skip to content

L2: the traffic simulator inserts the ego, Carla takes its dynamics over (#86) - #305

Draft
yunlishao-vibe wants to merge 15 commits into
dev_v0.9.0from
feature/86_l2_ego_sumo_inserted
Draft

L2: the traffic simulator inserts the ego, Carla takes its dynamics over (#86)#305
yunlishao-vibe wants to merge 15 commits into
dev_v0.9.0from
feature/86_l2_ego_sumo_inserted

Conversation

@yunlishao-vibe

@yunlishao-vibe yunlishao-vibe commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

VirCarlaEnv spawns the ego before the co-sim loop and requires EgoSpawnPose, 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 whether EgoSpawnPose is set, so nothing existing changes:

EgoSpawnPose who creates the ego
given unchanged: the bridge, before the loop; SUMO gets it injected
absent the traffic simulator; the bridge spawns a physics actor at the pose it reports, the first tick the ego id arrives

With 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-band world.Tick() there advances Carla past the feed and desyncs every mirrored vehicle.
  • TrafficHelper.cpp — the inject path adopts an ego SUMO already has. vehicleExist was computed at that site and never read; without it Vehicle::add throws "already exists" every step, and since the throw precedes the moveToXY/setSpeed in the same try, 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 checking EnableExternalControl. With that flag false there is no carlaOwnsId path to consume it, so it fell through to the ordinary application-layer branch and was applied as a plain setSpeed on 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: false control run fail to reproduce its own baseline, and it is invisible unless you diff a trajectory.

3. SumoSetup.EgoKeepRoute and a route-replacement guard

EgoKeepRoute (default 6, the previously hardcoded value) exposes the keepRoute bitmask for the mirror's moveToXY — 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_replaced fires 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.19 throttle. 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, DataLogger learned 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

  • Builds clean, Release x64.
  • Ran end to end on the MLK arterial with headless CARLA: SUMO inserts the ego at its own depart time, the bridge adopts it, and the pursuit driver takes it along the corridor tracking the eco controller's advisory.
  • With 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.
  • Ruled out with data during bring-up: no mirrored twin of the ego (0 of 1.23 M pose-log rows), spawn yaw correct (23.4° vs the road's 23.4°), elevation correct, and the road surface under the ego flat to ±0.00 m where it once launched.

Known / not settled

  • The driver's integral has no anti-windup; it fixes launch but overshoots at cruise (peak 37 m/s on a 13.4 m/s arterial).
  • With EnableExternalControl: false and EgoMode >= 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 use EgoMode: 0. Whether the injected case needs a collision-level fix as well is open.
  • The eco advisory can stall the ego at a crawl when the controller's own signal timing is wrong (e.g. behind a warm-up without Warm-up: serve the clients that have to observe it, and stop asking SUMO per vehicle (#86) #307), because the plan ramps from the measured speed.

Consumer: ORNL-Real-Sim/FIXS_Applications#41.

… 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.
@yunlishao-vibe yunlishao-vibe changed the title The traffic simulator inserts the ego, Carla takes it over (#86) Traffic-simulator-inserted ego, and the driver changes a physics ego needs (#86) Aug 11, 2026
… 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-vibe yunlishao-vibe changed the title Traffic-simulator-inserted ego, and the driver changes a physics ego needs (#86) Traffic-simulator-inserted ego, a warm-up that can serve its observers, and the driver changes a physics ego needs (#86) Aug 12, 2026
@yunlishao
yunlishao force-pushed the feature/86_l2_ego_sumo_inserted branch from 1441598 to e5b5019 Compare August 14, 2026 16:54
@yunlishao-vibe yunlishao-vibe changed the title Traffic-simulator-inserted ego, a warm-up that can serve its observers, and the driver changes a physics ego needs (#86) L2: the traffic simulator inserts the ego, Carla takes its dynamics over (#86) Aug 14, 2026
@yunlishao-vibe
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant