1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
//! Gravity-source registration and accessors for [`super::Simulation`].
//!
//! Methods: `add_source`, `set_source_ephemeris`, `frame_tree`,
//! `num_sources`, `source_frame`, `source_position`, `set_source_position`,
//! `set_source_state`, `source_pfix_rotation`, `source_tidal_config_mut`,
//! `source_delta_c20`.
use glam::{DMat3, DVec3};
use astrodyn::{
set_source_position as sim_set_source_position, set_source_state as sim_set_source_state,
source_pfix_rotation as sim_source_pfix_rotation, source_position as sim_source_position,
FrameId, FrameTree, GravitySourceEntry, JeodQuat, RefFrameKind, RefFrameRot, RefFrameState,
RefFrameTrans, RotationModel, SourceFrameIds,
};
use super::types::GravityData;
use super::Simulation;
impl Simulation {
/// Add a gravity source. Returns its index for use in `GravityControls`.
///
/// Sources with `central: true` (set by [`GravitySourceEntry::central_body`]
/// and [`GravitySourceEntry::central_body_sh`]) are mapped to the root frame.
/// Non-central sources get child inertial frames under the root.
///
/// Only one central source may be added; a second will panic.
///
/// If the source has a rotation model, a planet-fixed child frame is also
/// created under the source's inertial frame.
pub fn add_source(&mut self, name: impl Into<String>, entry: GravitySourceEntry) -> usize {
let idx = self.gravity_data.len();
let name = name.into();
// Central bodies map to the root frame; third bodies get child frames.
// Only one central source is allowed (the root can't be shared).
let inertial_name = format!("{name}.inertial");
let inertial_id = if entry.central {
assert!(
!self
.source_frame_ids
.iter()
.any(|sf| sf.inertial == self.root_frame_id),
"add_source: a central source already maps to root_frame_id. \
Only one central source is allowed per simulation."
);
assert!(
entry.position.raw_si() == DVec3::ZERO,
"add_source: central sources must have zero position because they map \
directly to root_frame_id."
);
// Central body: use the root frame directly. Rename to match.
// `entry.velocity` is stored in `gravity_data` for relativistic
// corrections, but is not applied as root-frame kinematics.
self.frame_tree.get_mut(self.root_frame_id).name = inertial_name;
self.root_frame_id
} else {
self.frame_tree.add_child(
self.root_frame_id,
inertial_name,
RefFrameKind::Inertial,
RefFrameState {
trans: RefFrameTrans {
// RefFrameTrans is the runtime arena's untyped storage —
// unwrap the typed entry value at this documented
// boundary (RF.10 catalog row notes the frame tree is
// runtime-typed by design).
position: entry.position.raw_si(),
velocity: entry.velocity.raw_si(),
},
rot: RefFrameRot::default(),
},
)
};
// Create a planet-fixed child when the source has a rotation model or
// an explicit inertial-to-pfix transform. This ensures a fixed initial
// orientation is not silently ignored when rotation_model is None.
let pfix_id =
if entry.rotation_model != RotationModel::None || entry.t_inertial_pfix.is_some() {
let pfix_name = format!("{name}.pfix");
let rot = if let Some(t) = entry.t_inertial_pfix {
RefFrameRot {
q_parent_this: JeodQuat::left_quat_from_transformation(&t),
t_parent_this: t,
ang_vel_this: DVec3::ZERO,
}
} else {
RefFrameRot::default()
};
Some(self.frame_tree.add_child(
inertial_id,
pfix_name,
RefFrameKind::PlanetFixed,
RefFrameState {
trans: RefFrameTrans::default(),
rot,
},
))
} else {
None
};
// Tidal ΔC20 requires a planet-fixed frame for the rotation matrix.
assert!(
entry.tidal_config.is_none() || pfix_id.is_some(),
"add_source: tidal_config requires a planet-fixed frame \
(set rotation_model or t_inertial_pfix on the source)."
);
self.source_frame_ids.push(SourceFrameIds {
inertial: inertial_id,
pfix: pfix_id,
});
self.gravity_data.push(GravityData {
source: entry.source,
// `GravityData.velocity` is runner-internal cache; unwrap the
// typed entry value at this documented runner boundary.
velocity: entry.velocity.raw_si(),
delta_c20: entry.delta_c20,
tidal_config: entry.tidal_config,
rotation_model: entry.rotation_model,
planet_omega: entry.planet_omega,
});
self.source_ephem_bodies.push(None);
idx
}
/// Configure ephemeris-based position updates for a source.
/// Each step, the source's position and velocity will be updated from DE4xx.
///
/// `target` is the body this source represents (e.g., `EphemerisBody::Sun`).
/// `observer` is the integration frame center (e.g., `EphemerisBody::Earth`).
pub fn set_source_ephemeris(
&mut self,
source_idx: usize,
target: astrodyn::EphemerisBody,
observer: astrodyn::EphemerisBody,
) {
assert!(
source_idx < self.source_ephem_bodies.len(),
"set_source_ephemeris: source_idx {source_idx} out of bounds (len = {})",
self.source_ephem_bodies.len()
);
// Root-frame conflict is caught by validate() → EphemerisOnRootSource.
// We don't panic here so that all misconfiguration errors are reported
// together in a single validate() pass rather than aborting on the first.
self.source_ephem_bodies[source_idx] = Some((target, observer));
}
/// Read-only access to the reference frame tree.
pub fn frame_tree(&self) -> &FrameTree {
&self.frame_tree
}
/// Number of gravity sources.
pub fn num_sources(&self) -> usize {
self.gravity_data.len()
}
/// Get the inertial frame ID for a gravity source.
pub fn source_frame(&self, source_idx: usize) -> FrameId {
self.source_frame_ids
.get(source_idx)
.unwrap_or_else(|| {
panic!(
"source_frame: source index {source_idx} is out of range; \
{} source frame(s) configured",
self.num_sources()
)
})
.inertial
}
/// Get the current position of a gravity source relative to the root
/// inertial frame. Returns `DVec3::ZERO` for the root-mapped central source.
pub fn source_position(&self, source_idx: usize) -> DVec3 {
sim_source_position(
&self.frame_tree,
&self.source_frame_ids,
self.root_frame_id,
source_idx,
)
}
/// Typed sibling of [`Self::source_position`] returning the source
/// position already wrapped as `Position<RootInertial>`. The
/// frame phantom matches the runner's root-inertial convention; per-
/// step pipelines that consume gravity / SRP / shadow inputs in
/// `RootInertial` should call this rather than re-lifting the raw
/// `DVec3` at every body.
pub fn source_position_typed(
&self,
source_idx: usize,
) -> astrodyn::Position<astrodyn::RootInertial> {
// allowed: typed-sibling accessor boundary. The frame phantom
// is the runner-wide convention (root frame is inertial); the
// raw kernel returns a frame-erased `DVec3` and the only source
// of truth for the phantom is the runner's documented root
// configuration.
astrodyn::Position::<astrodyn::RootInertial>::from_raw_si(self.source_position(source_idx))
}
/// Set the position of a gravity source relative to the root inertial frame.
pub fn set_source_position(&mut self, source_idx: usize, position: DVec3) {
sim_set_source_position(
&mut self.frame_tree,
&self.source_frame_ids,
self.root_frame_id,
source_idx,
position,
);
}
/// Set the position and velocity of a gravity source relative to the root inertial frame.
///
/// Prefer this over [`set_source_position`](Simulation::set_source_position)
/// when velocity is also available, to keep position and velocity consistent.
pub fn set_source_state(&mut self, source_idx: usize, position: DVec3, velocity: DVec3) {
sim_set_source_state(
&mut self.frame_tree,
&self.source_frame_ids,
self.root_frame_id,
source_idx,
position,
velocity,
);
// Keep gravity_data velocity in sync for relativistic corrections (PPN).
// Bounds already validated inside `sim_set_source_state` — `gravity_data`
// is parallel to `source_frame_ids`, so the same index is in range.
self.gravity_data[source_idx].velocity = velocity;
}
/// Get the planet-fixed rotation matrix for a gravity source. Returns `None`
/// if the source has no rotation model (no pfix frame).
pub fn source_pfix_rotation(&self, source_idx: usize) -> Option<DMat3> {
sim_source_pfix_rotation(&self.frame_tree, &self.source_frame_ids, source_idx)
}
/// Get the inertial-frame [`FrameId`] for a gravity source.
///
/// Useful for `Simulation::attach_to_frame(body_idx, parent_frame_id, …)`
/// callers that want to attach a body to (e.g.) Earth's inertial frame.
/// For the central body this returns the simulation's root frame.
pub fn source_inertial_frame_id(&self, source_idx: usize) -> FrameId {
let len = self.source_frame_ids.len();
self.source_frame_ids
.get(source_idx)
.unwrap_or_else(|| {
panic!(
"source_inertial_frame_id: source index {source_idx} out of \
range; {len} source(s) configured"
)
})
.inertial
}
/// Get the planet-fixed [`FrameId`] for a gravity source. Returns
/// `None` if the source has no rotation model (no pfix frame).
///
/// The companion to [`source_inertial_frame_id`](Self::source_inertial_frame_id)
/// for the rotating-frame attachment use case (JEOD's
/// `attach_to_frame("Earth.pfix")` pattern from
/// `verif/SIM_dyncomp/SET_test/RUN_attach_to_ref_frame/input.py`).
pub fn source_pfix_frame_id(&self, source_idx: usize) -> Option<FrameId> {
let len = self.source_frame_ids.len();
self.source_frame_ids
.get(source_idx)
.unwrap_or_else(|| {
panic!(
"source_pfix_frame_id: source index {source_idx} out of \
range; {len} source(s) configured"
)
})
.pfix
}
/// Get mutable access to a source's tidal configuration.
pub fn source_tidal_config_mut(
&mut self,
source_idx: usize,
) -> Option<&mut astrodyn::TidalConfig> {
let len = self.gravity_data.len();
self.gravity_data
.get_mut(source_idx)
.unwrap_or_else(|| {
panic!(
"source_tidal_config_mut: source index {source_idx} out of range; \
{len} source(s) configured",
)
})
.tidal_config
.as_mut()
}
/// Get the current ΔC20 tidal correction for a gravity source.
pub fn source_delta_c20(&self, source_idx: usize) -> f64 {
assert!(
source_idx < self.gravity_data.len(),
"source_delta_c20: source index {source_idx} out of range; \
{} source(s) configured",
self.gravity_data.len()
);
self.gravity_data[source_idx].delta_c20
}
}