1use crate::{
10 ballistics::{local_air, mach_table, Atmosphere},
11 ControlInput, Error, Guidance, GuidanceContext, Result, Vec3,
12};
13use ballistics_engine::drag::DragTable;
14
15#[derive(Debug, Clone)]
17pub struct WeathercockParams {
18 cna_per_rad: DragTable,
19 cp_m: DragTable,
20 cg_dry_m: f64,
21 cg_propellant_m: f64,
22 diameter_m: f64,
23 pitch_inertia_kg_m2: f64,
24 damping_ratio: f64,
25}
26
27impl WeathercockParams {
28 pub fn new(
36 cna_vs_mach: Vec<(f64, f64)>,
37 cp_vs_mach: Vec<(f64, f64)>,
38 cg_dry_m: f64,
39 cg_propellant_m: f64,
40 diameter_m: f64,
41 pitch_inertia_kg_m2: f64,
42 damping_ratio: f64,
43 ) -> Result<Self> {
44 if !cg_dry_m.is_finite() || !cg_propellant_m.is_finite() {
45 return Err(Error::invalid("cg", "CG references must be finite"));
46 }
47 if !diameter_m.is_finite() || diameter_m <= 0.0 {
48 return Err(Error::invalid("diameter_m", "must be finite and positive"));
49 }
50 if !pitch_inertia_kg_m2.is_finite() || pitch_inertia_kg_m2 <= 0.0 {
51 return Err(Error::invalid(
52 "pitch_inertia_kg_m2",
53 "must be finite and positive",
54 ));
55 }
56 if !damping_ratio.is_finite() || damping_ratio < 0.0 {
57 return Err(Error::invalid(
58 "damping_ratio",
59 "must be finite and nonnegative",
60 ));
61 }
62 Ok(Self {
63 cna_per_rad: mach_table(cna_vs_mach, "cna_vs_mach")?,
64 cp_m: mach_table(cp_vs_mach, "cp_vs_mach")?,
65 cg_dry_m,
66 cg_propellant_m,
67 diameter_m,
68 pitch_inertia_kg_m2,
69 damping_ratio,
70 })
71 }
72}
73
74#[derive(Debug, Clone)]
81pub struct Weathercock {
82 params: WeathercockParams,
83 atmosphere: Atmosphere,
84 atmos_params: (f64, f64, f64, f64),
85 wind_mps: Vec3,
86 axis: Vec3,
87 rate: Vec3,
88}
89
90impl Weathercock {
91 pub fn new(
94 params: WeathercockParams,
95 initial_axis: Vec3,
96 atmosphere: Atmosphere,
97 wind_mps: Vec3,
98 ) -> Result<Self> {
99 if initial_axis.amax() == 0.0 || !initial_axis.iter().all(|x| x.is_finite()) {
100 return Err(Error::invalid("initial_axis", "must be finite and nonzero"));
101 }
102 if !wind_mps.iter().all(|x| x.is_finite()) {
103 return Err(Error::invalid("wind_mps", "must be finite"));
104 }
105 Ok(Self {
106 atmos_params: atmosphere.parameters()?,
107 params,
108 atmosphere,
109 wind_mps,
110 axis: initial_axis.normalize(),
111 rate: Vec3::zeros(),
112 })
113 }
114
115 pub fn axis(&self) -> Vec3 {
117 self.axis
118 }
119 pub fn rate(&self) -> Vec3 {
121 self.rate
122 }
123 pub fn set_state(&mut self, axis: Vec3, rate: Vec3) -> Result<()> {
125 if axis.amax() == 0.0 || !axis.iter().all(|x| x.is_finite()) {
126 return Err(Error::invalid("axis", "must be finite and nonzero"));
127 }
128 self.axis = axis.normalize();
129 self.rate = rate;
130 Ok(())
131 }
132
133 fn cg_m(&self, context: &GuidanceContext<'_>) -> f64 {
134 let total = context.rocket.mass_kg;
135 let prop = context.rocket.motor.propellant_mass_kg;
136 if total <= 0.0 {
137 return self.params.cg_dry_m;
138 }
139 (self.params.cg_dry_m * (total - prop) + self.params.cg_propellant_m * prop) / total
140 }
141}
142
143impl Guidance for Weathercock {
144 fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
145 let dt = context.step_duration_s;
146 let v_air = context.state.velocity_mps - self.wind_mps;
147 let speed = v_air.norm();
148 if speed < 1e-9 {
149 return Ok(ControlInput::along(self.axis));
151 }
152 let target = v_air / speed;
153 let (rho, sound) = local_air(
154 self.atmosphere,
155 self.atmos_params,
156 context.state.position_m.y,
157 );
158 let mach = speed / sound;
159 let q = 0.5 * rho * speed * speed;
160 let area = std::f64::consts::FRAC_PI_4 * self.params.diameter_m.powi(2);
161 let cna = self.params.cna_per_rad.interpolate(mach);
162 let margin = self.params.cp_m.interpolate(mach) - self.cg_m(&context);
163 let omega_sq = q * area * cna * margin / self.params.pitch_inertia_kg_m2;
164
165 let cross = self.axis.cross(&target);
168 let anti_parallel = cross.norm() < 1e-12 && self.axis.dot(&target) < 0.0;
169 let cross = if anti_parallel && self.rate.norm() < 1e-12 {
170 let seed = if self.axis.z.abs() < 0.9 {
173 Vec3::z()
174 } else {
175 Vec3::y()
176 };
177 self.axis.cross(&seed).normalize() * 1e-6
178 } else {
179 cross
180 };
181 self.rate += cross * (omega_sq * dt);
182 let omega = omega_sq.abs().sqrt();
183 if omega > 0.0 {
184 self.rate *= (-2.0 * self.params.damping_ratio * omega * dt).exp();
185 }
186 self.rate -= self.axis * self.axis.dot(&self.rate);
188 let delta = self.rate.cross(&self.axis) * dt;
189 self.axis = (self.axis + delta).normalize();
190 Ok(ControlInput::along(self.axis))
191 }
192}
193
194#[derive(Debug, Clone, Copy)]
200pub struct RollDynamics {
201 rate_rad_s: f64,
202 cant_rad: f64,
203 cl_delta: f64,
204 clp: f64,
205 inertia_kg_m2: f64,
206 diameter_m: f64,
207}
208
209impl RollDynamics {
210 pub fn new(
214 cant_rad: f64,
215 cl_delta: f64,
216 clp: f64,
217 inertia_kg_m2: f64,
218 diameter_m: f64,
219 ) -> Result<Self> {
220 if !cant_rad.is_finite() || !cl_delta.is_finite() || !clp.is_finite() || clp <= 0.0 {
221 return Err(Error::invalid(
222 "roll parameters",
223 "cant and cl_delta must be finite, clp finite and positive",
224 ));
225 }
226 if !inertia_kg_m2.is_finite() || inertia_kg_m2 <= 0.0 {
227 return Err(Error::invalid(
228 "roll inertia_kg_m2",
229 "must be finite and positive",
230 ));
231 }
232 if !diameter_m.is_finite() || diameter_m <= 0.0 {
233 return Err(Error::invalid("diameter_m", "must be finite and positive"));
234 }
235 Ok(Self {
236 rate_rad_s: 0.0,
237 cant_rad,
238 cl_delta,
239 clp,
240 inertia_kg_m2,
241 diameter_m,
242 })
243 }
244
245 pub fn rate_rad_s(&self) -> f64 {
247 self.rate_rad_s
248 }
249 pub fn set_rate_rad_s(&mut self, rate: f64) {
251 self.rate_rad_s = rate;
252 }
253
254 pub fn update(&mut self, dt_s: f64, q_pa: f64, speed_mps: f64) {
256 let d = self.diameter_m;
257 let area = std::f64::consts::FRAC_PI_4 * d * d;
258 let drive = q_pa * area * d * self.cl_delta * self.cant_rad;
259 let damping =
260 q_pa * area * d * d * self.clp * self.rate_rad_s / (2.0 * speed_mps.max(1e-9));
261 self.rate_rad_s += (drive - damping) / self.inertia_kg_m2 * dt_s;
262 }
263
264 pub fn equilibrium_rate(&self, speed_mps: f64) -> f64 {
266 2.0 * self.cl_delta * self.cant_rad * speed_mps / (self.diameter_m * self.clp)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::{FlightState, MotorPhase, MotorSample, RocketSample};
274
275 fn params(margin_cp: f64) -> WeathercockParams {
276 WeathercockParams::new(
277 vec![(0.0, 2.0), (3.0, 2.0)],
278 vec![(0.0, margin_cp), (3.0, margin_cp)],
279 0.3,
280 0.3,
281 0.1,
282 0.05,
283 0.3,
284 )
285 .unwrap()
286 }
287
288 fn context(state: &FlightState) -> GuidanceContext<'_> {
289 GuidanceContext {
290 state,
291 rocket: RocketSample {
292 mass_kg: 1.0,
293 motor: MotorSample {
294 phase: MotorPhase::Unpowered,
295 thrust_n: 0.0,
296 propellant_mass_kg: 0.0,
297 },
298 },
299 step_duration_s: 0.01,
300 max_steering_accel_mps2: 0.0,
301 }
302 }
303
304 #[test]
305 fn stable_body_weathercocks_toward_the_wind() {
306 let params = params(0.6); let mut w = Weathercock::new(
308 params,
309 Vec3::new(1.0, 0.1, 0.0),
310 Atmosphere::default(),
311 Vec3::zeros(),
312 )
313 .unwrap();
314 let state = FlightState {
315 time_s: 0.0,
316 position_m: Vec3::zeros(),
317 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
318 };
319 for _ in 0..500 {
320 w.command(context(&state)).unwrap();
321 }
322 let dot = w.axis().dot(&Vec3::x());
323 assert!(dot > 0.999, "should align with the wind, dot {dot}");
324 }
325
326 #[test]
327 fn unstable_body_diverges() {
328 let params = params(0.1); let mut w = Weathercock::new(
330 params,
331 Vec3::new(1.0, 0.01, 0.0),
332 Atmosphere::default(),
333 Vec3::zeros(),
334 )
335 .unwrap();
336 let state = FlightState {
337 time_s: 0.0,
338 position_m: Vec3::zeros(),
339 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
340 };
341 for _ in 0..500 {
342 w.command(context(&state)).unwrap();
343 }
344 assert!(w.axis().dot(&Vec3::x()) < 0.9, "should tumble away");
345 }
346
347 #[test]
348 fn aligned_axis_stays_aligned() {
349 let params = params(0.6);
350 let mut w =
351 Weathercock::new(params, Vec3::x(), Atmosphere::default(), Vec3::zeros()).unwrap();
352 let state = FlightState {
353 time_s: 0.0,
354 position_m: Vec3::zeros(),
355 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
356 };
357 for _ in 0..100 {
358 w.command(context(&state)).unwrap();
359 }
360 assert!((w.axis() - Vec3::x()).norm() < 1e-12);
361 }
362
363 #[test]
364 fn roll_reaches_the_cant_equilibrium() {
365 let mut roll = RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.1).unwrap();
366 let q = 5000.0;
367 let v = 200.0;
368 for _ in 0..10000 {
370 roll.update(0.001, q, v);
371 }
372 let expected = roll.equilibrium_rate(v);
373 assert!(
374 (roll.rate_rad_s() - expected).abs() / expected.abs() < 0.01,
375 "rate {} vs equilibrium {expected}",
376 roll.rate_rad_s()
377 );
378 }
379
380 #[test]
381 fn uncanted_roll_decays_to_zero() {
382 let mut roll = RollDynamics::new(0.0, 1.0, 1.0, 0.001, 0.1).unwrap();
383 roll.set_rate_rad_s(50.0);
384 for _ in 0..10000 {
385 roll.update(0.001, 5000.0, 200.0);
386 }
387 assert!(roll.rate_rad_s().abs() < 0.01);
388 }
389
390 #[test]
391 fn weathercock_snapshot_restores_identically() {
392 let make = || {
393 Weathercock::new(
394 params(0.6),
395 Vec3::new(1.0, 0.1, 0.0),
396 Atmosphere::default(),
397 Vec3::zeros(),
398 )
399 .unwrap()
400 };
401 let state = FlightState {
402 time_s: 0.0,
403 position_m: Vec3::zeros(),
404 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
405 };
406 let mut a = make();
407 for _ in 0..50 {
408 a.command(context(&state)).unwrap();
409 }
410 let (axis, rate) = (a.axis(), a.rate());
411 let mut b = make();
412 b.set_state(axis, rate).unwrap();
413 for _ in 0..50 {
414 let ca = a.command(context(&state)).unwrap();
415 let cb = b.command(context(&state)).unwrap();
416 assert!(
418 (ca.thrust_direction - cb.thrust_direction).norm() < 1e-12,
419 "{ca:?} vs {cb:?}"
420 );
421 }
422 }
423
424 #[test]
425 fn weathercock_params_validate() {
426 assert!(WeathercockParams::new(
427 vec![(0.0, 2.0)],
428 vec![(0.0, 0.6), (3.0, 0.6)],
429 0.3,
430 0.3,
431 0.1,
432 0.05,
433 0.3
434 )
435 .is_err());
436 assert!(WeathercockParams::new(
437 vec![(0.0, 2.0), (3.0, 2.0)],
438 vec![(0.0, 0.6), (3.0, 0.6)],
439 0.3,
440 0.3,
441 0.1,
442 0.0,
443 0.3
444 )
445 .is_err());
446 assert!(WeathercockParams::new(
447 vec![(0.0, 2.0), (3.0, 2.0)],
448 vec![(0.0, 0.6), (3.0, 0.6)],
449 0.3,
450 0.3,
451 0.1,
452 0.05,
453 -0.1
454 )
455 .is_err());
456 }
457
458 #[test]
459 fn roll_params_validate() {
460 assert!(RollDynamics::new(0.02, 1.0, 0.0, 0.001, 0.1).is_err());
461 assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.0, 0.1).is_err());
462 assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.0).is_err());
463 }
464
465 #[test]
466 fn underdamped_response_oscillates_near_the_natural_frequency() {
467 let undamped = WeathercockParams::new(
470 vec![(0.0, 2.0), (3.0, 2.0)],
471 vec![(0.0, 0.6), (3.0, 0.6)],
472 0.3,
473 0.3,
474 0.1,
475 0.05,
476 0.0,
477 )
478 .unwrap();
479 let mut w = Weathercock::new(
480 undamped,
481 Vec3::new(1.0, 0.1, 0.0),
482 Atmosphere::default(),
483 Vec3::zeros(),
484 )
485 .unwrap();
486 let state = FlightState {
487 time_s: 0.0,
488 position_m: Vec3::zeros(),
489 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
490 };
491 let steps = 100; let mut previous_sign = 1.0_f64;
493 let mut crossings = 0;
494 for _ in 0..steps {
495 w.command(context(&state)).unwrap();
496 let off = w.axis().y;
497 if off.signum() != previous_sign && off != 0.0 {
498 crossings += 1;
499 previous_sign = off.signum();
500 }
501 }
502 let q = 0.5 * 1.225 * 100.0 * 100.0;
503 let area = std::f64::consts::FRAC_PI_4 * 0.1 * 0.1;
504 let omega_n = (q * area * 2.0 * (0.6 - 0.3) / 0.05).sqrt();
505 let elapsed = steps as f64 * 0.01;
506 let expected_crossings = omega_n * elapsed / std::f64::consts::PI;
507 let ratio = crossings as f64 / expected_crossings;
508 assert!(
509 (0.75..=1.3).contains(&ratio),
510 "crossings {crossings} vs expected {expected_crossings:.1} (ratio {ratio})"
511 );
512 }
513}