You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
While porting verif/SIM_ground_contact to a Rust reimplementation
(this repo, issues #88 / #205), we traced the CSV trajectory of SET_test/RUN_contact_ground to an initialization-state artifact
in ContactGround::initialize_ground: a pre-propagation GroundInteraction::initialize() → in_contact() call writes a spurious
impulsive force (~1.117 × 10¹⁰ N per vehicle) to subject->force,
which the integrator then consumes at stage 1 of the first RK4 step
(weight 1/6). The trajectory the simulation actually produces — vehicles
launching outward at ~93 km/s within 50 ms of "rest at the planet
surface" — is therefore not the steady-state contact algorithm at work;
it's an interaction between the timing of BodyRefFrame state
propagation and the order of P_DYN("initialization") jobs.
The artifact is fully reproducible from the source tree as committed;
no patch needed to observe it. We're filing this so the next reader of SIM_ground_contact (us, the JEOD team, or anyone else porting it)
doesn't re-derive the analysis from scratch.
models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/contact_ground.cc:146-184
— ContactGround::initialize_ground iterates (ground_facets × contact_pairs) and calls GroundInteraction::initialize for each
unique subject.
models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/ground_interaction.cc:37-42
— GroundInteraction::initialize(...) { … in_contact(); } invokes in_contact() once during init.
models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/point_ground_interaction.cc::in_contact
/ line_ground_interaction.cc::in_contact — the algorithm reads vehicle_point->state.trans.position for the facet_pos term.
models/dynamics/dyn_body/src/dyn_body_propagate_state.cc:159-198
— compute_derived_state_forward(structure, mass_point, derived)
populates derived.state.trans.position = structure.position + T_struct→inertial · mass_point.position. Until this runs, vehicle_point.state.trans.position is the default-constructed (0, 0, 0).
What the algorithm does at init vs runtime
point_ground_interaction.cc::in_contact (and the line variant) reads vehicle_point->state.trans.position in two places — to assemble vec for the terrain query and to compute facet_pos for the subject_mag < ground_mag test:
For a vehicle initialized via trans_init at inertial position (R, 0, 0) (where R = 6378137 m is Earth's equatorial radius), with
mass center at the structure origin and identity attitude:
rel_state = contact_point + 0 ≈ (1, 0, 0) → subject_mag ≈ 1 << R = ground_mag → contact triggers with depth ≈ R, force ≈ k · R ≈ 1.117 × 10¹⁰ N.
The force is added to subject->force — and, because ContactSurface::collect_forces_torques only zeros facet.force after
its own derivative-class collection, the value persists through the
end of init.
Runtime call (vp.state.trans.position propagated to inertial position)
After compute_derived_state_forward runs (from dyn_manager.initialize or the first propagate_state_from_structure
during the integrator loop), vehicle_point.state.trans.position
becomes (R, 0, 0). Now:
vec = (R, 0, 0) + (R, 0, 0) = (2R, 0, 0) — the doubled inertial
position (consistent because vp.pos is now the vehicle's own
inertial position, summed with the structure's inertial position).
Terrain query at altitude R gives the same body-frame ground_point ≈ (R, 0, 0).
facet_pos = T · (R, 0, 0) = (R, 0, 0).
rel_state = (1, 0, 0) + (R, 0, 0) = (R + 1, 0, 0) → subject_mag = R + 1 > R = ground_mag → no contact at any altitude (including h = 0).
So the steady-state algorithm reports no contact for any vehicle on or
above the surface — physically correct (a body just resting on the
ground has no penetration, no spring force).
Net trajectory
The init-time impulse F = k · R ≈ 1.117 × 10¹⁰ N is consumed
at stage 1 of the first RK4 step (ContactSurface::collect_forces_torques
zeros facet.force between stages, so stages 2–4 of step 1 see no
force, and the steady-state algorithm reports no contact thereafter).
Standard RK4 with force only at stage 1 yields:
Δv_step1 = (1/6) · F · dt / m
≈ (1/6) · 1.117 × 10¹⁰ · 0.01 / 200
≈ 9.31 × 10⁴ m/s
This matches the t = 0.05 CSV velocity (93081.39 m/s for the line, 93081.40 m/s for the point) to bit-precision (~picometers/second
in our pure-Rust port once we model the artifact explicitly with the
NIST-exact lbf/in conversion).
CSV contact_force doubling
The contact_surface.contact_force[0] column at t = 0 shows 22 339 651 946.5 N — exactly 2 × F. A plausible explanation: P_DYN("derivative") jobs run once during dyn_manager.initialize
(after initialize_ground) before the data recorder samples at t = 0. With vp.state.trans.position still at (0, 0, 0) at that
moment, check_contact_ground re-evaluates in_contact and adds a
second copy of F to subject->force. collect_forces_torques
then sums the two contributions into contact_surface.contact_force,
giving 2F — but only at the first sample, before the integrator's
first step propagates vp.state.trans.position. We haven't
verified this with a JEOD live-run trace; the trajectory match
demonstrates the integrator only consumes a single F.
Reproduction steps
To verify against JEOD itself:
Check outnasa/jeod and nasa/trick.
Build and runmodels/interactions/contact/verif/SIM_ground_contact/SET_test/RUN_contact_ground
with the standard trick-CP flow (no patches).
Inspectlog_contact_data.csv:
Row at t = 0: contact_force[0] ≈ 2.234 × 10¹⁰ N, position = (R, 0, 0), velocity = (0, 0, 0).
Row at t = 0.05: contact_force = 0, velocity ≈ 93 081 m/s.
Add a logging breakpoint (or MessageHandler::send) inside PointGroundInteraction::in_contact and LineGroundInteraction::in_contact printing vehicle_point->state.trans.position and subject_mag, ground_mag at every entry. Expected output:
Second call (derivative class during dyn_manager.initialize,
before the first integrator step): vp.pos = (0, 0, 0) still, contact = true again — second F
added to subject->force.
Third call onward (every RK4 stage from step 1): vp.pos = (R, 0, 0), subject_mag = R + 1, contact = false.
The printout will confirm the "two pre-step in_contact calls write
into subject->force while vp.pos is still default-constructed"
hypothesis.
What our port did
To reproduce JEOD's CSV trajectory bit-for-bit, our Rust port models
the artifact explicitly:
A Phase enum on compute_ground_contact_geometry with variants Initialization (pre-propagation, facet_pos = (0, 0, 0)) and SteadyState (post-propagation, facet_pos = T_inertial→body · vehicle_pos_inertial).
Simulation::register_ground_contact_pair evaluates the Initialization phase at registration and stashes the impulse on
the pair config.
The contact-coupled RK4 closure consumes the pending impulse at
stage 1 of the first step (mirroring collect_forces_torques's facet.force = 0 clearing) and evaluates Phase::SteadyState
at every stage thereafter.
This achieves a position match of ~3 nm and velocity match of ~5 × 10⁻¹¹ m/s against SET_test/RUN_contact_ground over 10 s.
Suggested action for nasa/jeod
The verification sim runs without error and the unit tests don't
exercise the init-time in_contact call, so this is invisible to
JEOD's own CI. Possible mitigations the JEOD team may want to consider:
Document the behavior in models/interactions/contact/docs/tex/guide.tex so the next reader
doesn't infer that SIM_ground_contact's ~93 km/s launch is
physically meaningful.
Move GroundInteraction::initialize's in_contact() call to
after dyn_manager.initialize's state-propagation phase (or guard
it behind a check that vehicle_point has been propagated).
Reset subject->force to zero at the end of ContactGround::initialize_ground so the init-time impulse doesn't
leak into the integrator. (Conceptually equivalent to calling collect_forces_torques once at the end of init.)
We're happy to share the Rust port's reproduction harness or the
diagnostic instrumentation we added if useful.
References
This investigation was driven by porting work in our Rust
reimplementation: this repo issue Port GroundFacet to enable SIM_ground_contact Tier 3 cross-validation #88, branch claude/prepare-issue-88-TYI0d. The Rust test crates/jeod_runner/tests/tier3_sim_contact.rs::tier3_contact_ground
validates the bit-for-bit match against the committed reference CSV.
Investigation summary commits: e698b7e, 051e2bb, 20a01e9 in
this branch.
Summary (for the JEOD team)
While porting
verif/SIM_ground_contactto a Rust reimplementation(this repo, issues #88 / #205), we traced the CSV trajectory of
SET_test/RUN_contact_groundto an initialization-state artifactin
ContactGround::initialize_ground: a pre-propagationGroundInteraction::initialize() → in_contact()call writes a spuriousimpulsive force (~1.117 × 10¹⁰ N per vehicle) to
subject->force,which the integrator then consumes at stage 1 of the first RK4 step
(weight 1/6). The trajectory the simulation actually produces — vehicles
launching outward at ~93 km/s within 50 ms of "rest at the planet
surface" — is therefore not the steady-state contact algorithm at work;
it's an interaction between the timing of
BodyRefFramestatepropagation and the order of
P_DYN("initialization")jobs.The artifact is fully reproducible from the source tree as committed;
no patch needed to observe it. We're filing this so the next reader of
SIM_ground_contact(us, the JEOD team, or anyone else porting it)doesn't re-derive the analysis from scratch.
Code references
All paths relative to
nasa/jeod:models/interactions/contact/verif/SIM_ground_contact/S_modules/contact.sm:70-71—
P_DYN("initialization") contact.initialize_ground(...)/initialize_contact(...)job order.models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/contact_ground.cc:146-184—
ContactGround::initialize_grounditerates(ground_facets × contact_pairs)and callsGroundInteraction::initializefor eachunique subject.
models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/ground_interaction.cc:37-42—
GroundInteraction::initialize(...) { … in_contact(); }invokesin_contact()once during init.models/interactions/contact/verif/SIM_ground_contact/models/contact_ground/src/point_ground_interaction.cc::in_contact/
line_ground_interaction.cc::in_contact— the algorithm readsvehicle_point->state.trans.positionfor thefacet_posterm.models/dynamics/dyn_body/src/dyn_body_propagate_state.cc:159-198—
compute_derived_state_forward(structure, mass_point, derived)populates
derived.state.trans.position = structure.position + T_struct→inertial · mass_point.position. Until this runs,vehicle_point.state.trans.positionis the default-constructed(0, 0, 0).What the algorithm does at init vs runtime
point_ground_interaction.cc::in_contact(and the line variant) readsvehicle_point->state.trans.positionin two places — to assemblevecfor the terrain query and to computefacet_posfor thesubject_mag < ground_magtest:For a vehicle initialized via
trans_initat inertial position(R, 0, 0)(whereR = 6378137 mis Earth's equatorial radius), withmass center at the structure origin and identity attitude:
Init-time call (vp.state.trans.position == (0, 0, 0) — default-constructed)
vec = (R, 0, 0) + (0, 0, 0) = (R, 0, 0)— interpreted as thevehicle's inertial position.
ground_pointbody-frame ≈(R, 0, 0).contact_point = (1, 0, 0)for the point sphere (radius 1) or(2, 0, 0)for the line cylinder (length 2 cap-end + radius).facet_pos = T_parent_this · (0, 0, 0) = (0, 0, 0).rel_state = contact_point + 0 ≈ (1, 0, 0)→subject_mag ≈ 1 << R = ground_mag→contact triggers with depth ≈ R, force ≈ k · R ≈ 1.117 × 10¹⁰ N.
The force is added to
subject->force— and, becauseContactSurface::collect_forces_torquesonly zerosfacet.forceafterits own derivative-class collection, the value persists through the
end of init.
Runtime call (vp.state.trans.position propagated to inertial position)
After
compute_derived_state_forwardruns (fromdyn_manager.initializeor the firstpropagate_state_from_structureduring the integrator loop),
vehicle_point.state.trans.positionbecomes
(R, 0, 0). Now:vec = (R, 0, 0) + (R, 0, 0) = (2R, 0, 0)— the doubled inertialposition (consistent because vp.pos is now the vehicle's own
inertial position, summed with the structure's inertial position).
Rgives the same body-frameground_point ≈ (R, 0, 0).facet_pos = T · (R, 0, 0) = (R, 0, 0).rel_state = (1, 0, 0) + (R, 0, 0) = (R + 1, 0, 0)→subject_mag = R + 1 > R = ground_mag→no contact at any altitude (including h = 0).
So the steady-state algorithm reports no contact for any vehicle on or
above the surface — physically correct (a body just resting on the
ground has no penetration, no spring force).
Net trajectory
The init-time impulse
F = k · R ≈ 1.117 × 10¹⁰ Nis consumedat stage 1 of the first RK4 step (
ContactSurface::collect_forces_torqueszeros
facet.forcebetween stages, so stages 2–4 of step 1 see noforce, and the steady-state algorithm reports no contact thereafter).
Standard RK4 with force only at stage 1 yields:
This matches the t = 0.05 CSV velocity (
93081.39 m/sfor the line,93081.40 m/sfor the point) to bit-precision (~picometers/secondin our pure-Rust port once we model the artifact explicitly with the
NIST-exact
lbf/inconversion).CSV
contact_forcedoublingThe
contact_surface.contact_force[0]column att = 0shows22 339 651 946.5 N— exactly 2 × F. A plausible explanation:P_DYN("derivative")jobs run once duringdyn_manager.initialize(after
initialize_ground) before the data recorder samples att = 0. Withvp.state.trans.positionstill at(0, 0, 0)at thatmoment,
check_contact_groundre-evaluatesin_contactand adds asecond copy of
Ftosubject->force.collect_forces_torquesthen sums the two contributions into
contact_surface.contact_force,giving
2F— but only at the first sample, before the integrator'sfirst step propagates
vp.state.trans.position. We haven'tverified this with a JEOD live-run trace; the trajectory match
demonstrates the integrator only consumes a single
F.Reproduction steps
To verify against JEOD itself:
nasa/jeodandnasa/trick.models/interactions/contact/verif/SIM_ground_contact/SET_test/RUN_contact_groundwith the standard
trick-CPflow (no patches).log_contact_data.csv:t = 0:contact_force[0] ≈ 2.234 × 10¹⁰ N,position = (R, 0, 0),velocity = (0, 0, 0).t = 0.05:contact_force = 0,velocity ≈ 93 081 m/s.MessageHandler::send) insidePointGroundInteraction::in_contactandLineGroundInteraction::in_contactprintingvehicle_point->state.trans.positionandsubject_mag,ground_magat every entry. Expected output:initialize_ground):vp.pos = (0, 0, 0),subject_mag ≈ 1,ground_mag ≈ 6 378 137, contact = true.dyn_manager.initialize,before the first integrator step):
vp.pos = (0, 0, 0)still, contact = true again — secondFadded to
subject->force.vp.pos = (R, 0, 0),subject_mag = R + 1, contact = false.The printout will confirm the "two pre-step
in_contactcalls writeinto
subject->forcewhilevp.posis still default-constructed"hypothesis.
What our port did
To reproduce JEOD's CSV trajectory bit-for-bit, our Rust port models
the artifact explicitly:
Phaseenum oncompute_ground_contact_geometrywith variantsInitialization(pre-propagation,facet_pos = (0, 0, 0)) andSteadyState(post-propagation,facet_pos = T_inertial→body · vehicle_pos_inertial).Simulation::register_ground_contact_pairevaluates theInitializationphase at registration and stashes the impulse onthe pair config.
stage 1 of the first step (mirroring
collect_forces_torques'sfacet.force = 0clearing) and evaluatesPhase::SteadyStateat every stage thereafter.
This achieves a position match of
~3 nmand velocity match of~5 × 10⁻¹¹ m/sagainstSET_test/RUN_contact_groundover 10 s.Suggested action for nasa/jeod
The verification sim runs without error and the unit tests don't
exercise the init-time
in_contactcall, so this is invisible toJEOD's own CI. Possible mitigations the JEOD team may want to consider:
models/interactions/contact/docs/tex/guide.texso the next readerdoesn't infer that
SIM_ground_contact's ~93 km/s launch isphysically meaningful.
GroundInteraction::initialize'sin_contact()call toafter
dyn_manager.initialize's state-propagation phase (or guardit behind a check that
vehicle_pointhas been propagated).subject->forceto zero at the end ofContactGround::initialize_groundso the init-time impulse doesn'tleak into the integrator. (Conceptually equivalent to calling
collect_forces_torquesonce at the end of init.)We're happy to share the Rust port's reproduction harness or the
diagnostic instrumentation we added if useful.
References
reimplementation: this repo issue Port GroundFacet to enable SIM_ground_contact Tier 3 cross-validation #88, branch
claude/prepare-issue-88-TYI0d. The Rust testcrates/jeod_runner/tests/tier3_sim_contact.rs::tier3_contact_groundvalidates the bit-for-bit match against the committed reference CSV.
e698b7e,051e2bb,20a01e9inthis branch.