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 turbulence: Option<crate::ballistics::Turbulence>,
87 axis: Vec3,
88 rate: Vec3,
89}
90
91impl Weathercock {
92 pub fn new(
95 params: WeathercockParams,
96 initial_axis: Vec3,
97 atmosphere: Atmosphere,
98 wind_mps: Vec3,
99 ) -> Result<Self> {
100 if initial_axis.amax() == 0.0 || !initial_axis.iter().all(|x| x.is_finite()) {
101 return Err(Error::invalid("initial_axis", "must be finite and nonzero"));
102 }
103 if !wind_mps.iter().all(|x| x.is_finite()) {
104 return Err(Error::invalid("wind_mps", "must be finite"));
105 }
106 Ok(Self {
107 atmos_params: atmosphere.parameters()?,
108 params,
109 atmosphere,
110 wind_mps,
111 turbulence: None,
112 axis: initial_axis.normalize(),
113 rate: Vec3::zeros(),
114 })
115 }
116
117 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
121 self.turbulence = Some(crate::ballistics::Turbulence::new(sigma_mps, tau_s, seed)?);
122 Ok(self)
123 }
124
125 pub fn axis(&self) -> Vec3 {
127 self.axis
128 }
129 pub fn rate(&self) -> Vec3 {
131 self.rate
132 }
133 pub fn set_state(&mut self, axis: Vec3, rate: Vec3) -> Result<()> {
135 if axis.amax() == 0.0 || !axis.iter().all(|x| x.is_finite()) {
136 return Err(Error::invalid("axis", "must be finite and nonzero"));
137 }
138 self.axis = axis.normalize();
139 self.rate = rate;
140 Ok(())
141 }
142
143 fn cg_m(&self, context: &GuidanceContext<'_>) -> f64 {
144 let total = context.rocket.mass_kg;
145 let prop = context.rocket.motor.propellant_mass_kg;
146 if total <= 0.0 {
147 return self.params.cg_dry_m;
148 }
149 (self.params.cg_dry_m * (total - prop) + self.params.cg_propellant_m * prop) / total
150 }
151}
152
153impl Guidance for Weathercock {
154 fn command(&mut self, context: GuidanceContext<'_>) -> Result<ControlInput> {
155 let dt = context.step_duration_s;
156 let wind = match &self.turbulence {
157 Some(t) => self.wind_mps + t.gust_vec(context.state.time_s),
158 None => self.wind_mps,
159 };
160 let v_air = context.state.velocity_mps - wind;
161 let speed = v_air.norm();
162 if speed < 1e-9 {
163 return Ok(ControlInput::along(self.axis));
165 }
166 let target = v_air / speed;
167 let (rho, sound) = local_air(
168 self.atmosphere,
169 self.atmos_params,
170 context.state.position_m.y,
171 );
172 let mach = speed / sound;
173 let q = 0.5 * rho * speed * speed;
174 let area = std::f64::consts::FRAC_PI_4 * self.params.diameter_m.powi(2);
175 let cna = self.params.cna_per_rad.interpolate(mach);
176 let margin = self.params.cp_m.interpolate(mach) - self.cg_m(&context);
177 let omega_sq = q * area * cna * margin / self.params.pitch_inertia_kg_m2;
178
179 let cross = self.axis.cross(&target);
182 let anti_parallel = cross.norm() < 1e-12 && self.axis.dot(&target) < 0.0;
183 let cross = if anti_parallel && self.rate.norm() < 1e-12 {
184 let seed = if self.axis.z.abs() < 0.9 {
187 Vec3::z()
188 } else {
189 Vec3::y()
190 };
191 self.axis.cross(&seed).normalize() * 1e-6
192 } else {
193 cross
194 };
195 self.rate += cross * (omega_sq * dt);
196 let omega = omega_sq.abs().sqrt();
197 if omega > 0.0 {
198 self.rate *= (-2.0 * self.params.damping_ratio * omega * dt).exp();
199 }
200 self.rate -= self.axis * self.axis.dot(&self.rate);
202 let delta = self.rate.cross(&self.axis) * dt;
203 self.axis = (self.axis + delta).normalize();
204 Ok(ControlInput::along(self.axis))
205 }
206}
207
208#[derive(Debug, Clone, Copy)]
214pub struct RollDynamics {
215 rate_rad_s: f64,
216 cant_rad: f64,
217 cl_delta: f64,
218 clp: f64,
219 inertia_kg_m2: f64,
220 diameter_m: f64,
221}
222
223impl RollDynamics {
224 pub fn new(
228 cant_rad: f64,
229 cl_delta: f64,
230 clp: f64,
231 inertia_kg_m2: f64,
232 diameter_m: f64,
233 ) -> Result<Self> {
234 if !cant_rad.is_finite() || !cl_delta.is_finite() || !clp.is_finite() || clp <= 0.0 {
235 return Err(Error::invalid(
236 "roll parameters",
237 "cant and cl_delta must be finite, clp finite and positive",
238 ));
239 }
240 if !inertia_kg_m2.is_finite() || inertia_kg_m2 <= 0.0 {
241 return Err(Error::invalid(
242 "roll inertia_kg_m2",
243 "must be finite and positive",
244 ));
245 }
246 if !diameter_m.is_finite() || diameter_m <= 0.0 {
247 return Err(Error::invalid("diameter_m", "must be finite and positive"));
248 }
249 Ok(Self {
250 rate_rad_s: 0.0,
251 cant_rad,
252 cl_delta,
253 clp,
254 inertia_kg_m2,
255 diameter_m,
256 })
257 }
258
259 pub fn rate_rad_s(&self) -> f64 {
261 self.rate_rad_s
262 }
263 pub fn set_rate_rad_s(&mut self, rate: f64) {
265 self.rate_rad_s = rate;
266 }
267
268 pub fn update(&mut self, dt_s: f64, q_pa: f64, speed_mps: f64) {
270 let d = self.diameter_m;
271 let area = std::f64::consts::FRAC_PI_4 * d * d;
272 let drive = q_pa * area * d * self.cl_delta * self.cant_rad;
273 let damping =
274 q_pa * area * d * d * self.clp * self.rate_rad_s / (2.0 * speed_mps.max(1e-9));
275 self.rate_rad_s += (drive - damping) / self.inertia_kg_m2 * dt_s;
276 }
277
278 pub fn equilibrium_rate(&self, speed_mps: f64) -> f64 {
280 2.0 * self.cl_delta * self.cant_rad * speed_mps / (self.diameter_m * self.clp)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::{FlightState, MotorPhase, MotorSample, RocketSample};
288
289 fn params(margin_cp: f64) -> WeathercockParams {
290 WeathercockParams::new(
291 vec![(0.0, 2.0), (3.0, 2.0)],
292 vec![(0.0, margin_cp), (3.0, margin_cp)],
293 0.3,
294 0.3,
295 0.1,
296 0.05,
297 0.3,
298 )
299 .unwrap()
300 }
301
302 fn context(state: &FlightState) -> GuidanceContext<'_> {
303 GuidanceContext {
304 state,
305 rocket: RocketSample {
306 mass_kg: 1.0,
307 motor: MotorSample {
308 phase: MotorPhase::Unpowered,
309 thrust_n: 0.0,
310 propellant_mass_kg: 0.0,
311 },
312 },
313 step_duration_s: 0.01,
314 max_steering_accel_mps2: 0.0,
315 }
316 }
317
318 #[test]
319 fn stable_body_weathercocks_toward_the_wind() {
320 let params = params(0.6); let mut w = Weathercock::new(
322 params,
323 Vec3::new(1.0, 0.1, 0.0),
324 Atmosphere::default(),
325 Vec3::zeros(),
326 )
327 .unwrap();
328 let state = FlightState {
329 time_s: 0.0,
330 position_m: Vec3::zeros(),
331 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
332 };
333 for _ in 0..500 {
334 w.command(context(&state)).unwrap();
335 }
336 let dot = w.axis().dot(&Vec3::x());
337 assert!(dot > 0.999, "should align with the wind, dot {dot}");
338 }
339
340 #[test]
341 fn unstable_body_diverges() {
342 let params = params(0.1); let mut w = Weathercock::new(
344 params,
345 Vec3::new(1.0, 0.01, 0.0),
346 Atmosphere::default(),
347 Vec3::zeros(),
348 )
349 .unwrap();
350 let state = FlightState {
351 time_s: 0.0,
352 position_m: Vec3::zeros(),
353 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
354 };
355 for _ in 0..500 {
356 w.command(context(&state)).unwrap();
357 }
358 assert!(w.axis().dot(&Vec3::x()) < 0.9, "should tumble away");
359 }
360
361 #[test]
362 fn aligned_axis_stays_aligned() {
363 let params = params(0.6);
364 let mut w =
365 Weathercock::new(params, Vec3::x(), Atmosphere::default(), Vec3::zeros()).unwrap();
366 let state = FlightState {
367 time_s: 0.0,
368 position_m: Vec3::zeros(),
369 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
370 };
371 for _ in 0..100 {
372 w.command(context(&state)).unwrap();
373 }
374 assert!((w.axis() - Vec3::x()).norm() < 1e-12);
375 }
376
377 #[test]
378 fn roll_reaches_the_cant_equilibrium() {
379 let mut roll = RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.1).unwrap();
380 let q = 5000.0;
381 let v = 200.0;
382 for _ in 0..10000 {
384 roll.update(0.001, q, v);
385 }
386 let expected = roll.equilibrium_rate(v);
387 assert!(
388 (roll.rate_rad_s() - expected).abs() / expected.abs() < 0.01,
389 "rate {} vs equilibrium {expected}",
390 roll.rate_rad_s()
391 );
392 }
393
394 #[test]
395 fn uncanted_roll_decays_to_zero() {
396 let mut roll = RollDynamics::new(0.0, 1.0, 1.0, 0.001, 0.1).unwrap();
397 roll.set_rate_rad_s(50.0);
398 for _ in 0..10000 {
399 roll.update(0.001, 5000.0, 200.0);
400 }
401 assert!(roll.rate_rad_s().abs() < 0.01);
402 }
403
404 #[test]
405 fn weathercock_snapshot_restores_identically() {
406 let make = || {
407 Weathercock::new(
408 params(0.6),
409 Vec3::new(1.0, 0.1, 0.0),
410 Atmosphere::default(),
411 Vec3::zeros(),
412 )
413 .unwrap()
414 };
415 let state = FlightState {
416 time_s: 0.0,
417 position_m: Vec3::zeros(),
418 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
419 };
420 let mut a = make();
421 for _ in 0..50 {
422 a.command(context(&state)).unwrap();
423 }
424 let (axis, rate) = (a.axis(), a.rate());
425 let mut b = make();
426 b.set_state(axis, rate).unwrap();
427 for _ in 0..50 {
428 let ca = a.command(context(&state)).unwrap();
429 let cb = b.command(context(&state)).unwrap();
430 assert!(
432 (ca.thrust_direction - cb.thrust_direction).norm() < 1e-12,
433 "{ca:?} vs {cb:?}"
434 );
435 }
436 }
437
438 #[test]
439 fn weathercock_params_validate() {
440 assert!(WeathercockParams::new(
441 vec![(0.0, 2.0)],
442 vec![(0.0, 0.6), (3.0, 0.6)],
443 0.3,
444 0.3,
445 0.1,
446 0.05,
447 0.3
448 )
449 .is_err());
450 assert!(WeathercockParams::new(
451 vec![(0.0, 2.0), (3.0, 2.0)],
452 vec![(0.0, 0.6), (3.0, 0.6)],
453 0.3,
454 0.3,
455 0.1,
456 0.0,
457 0.3
458 )
459 .is_err());
460 assert!(WeathercockParams::new(
461 vec![(0.0, 2.0), (3.0, 2.0)],
462 vec![(0.0, 0.6), (3.0, 0.6)],
463 0.3,
464 0.3,
465 0.1,
466 0.05,
467 -0.1
468 )
469 .is_err());
470 }
471
472 #[test]
473 fn roll_params_validate() {
474 assert!(RollDynamics::new(0.02, 1.0, 0.0, 0.001, 0.1).is_err());
475 assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.0, 0.1).is_err());
476 assert!(RollDynamics::new(0.02, 1.0, 1.0, 0.001, 0.0).is_err());
477 }
478
479 #[test]
480 fn underdamped_response_oscillates_near_the_natural_frequency() {
481 let undamped = WeathercockParams::new(
484 vec![(0.0, 2.0), (3.0, 2.0)],
485 vec![(0.0, 0.6), (3.0, 0.6)],
486 0.3,
487 0.3,
488 0.1,
489 0.05,
490 0.0,
491 )
492 .unwrap();
493 let mut w = Weathercock::new(
494 undamped,
495 Vec3::new(1.0, 0.1, 0.0),
496 Atmosphere::default(),
497 Vec3::zeros(),
498 )
499 .unwrap();
500 let state = FlightState {
501 time_s: 0.0,
502 position_m: Vec3::zeros(),
503 velocity_mps: Vec3::new(100.0, 0.0, 0.0),
504 };
505 let steps = 100; let mut previous_sign = 1.0_f64;
507 let mut crossings = 0;
508 for _ in 0..steps {
509 w.command(context(&state)).unwrap();
510 let off = w.axis().y;
511 if off.signum() != previous_sign && off != 0.0 {
512 crossings += 1;
513 previous_sign = off.signum();
514 }
515 }
516 let q = 0.5 * 1.225 * 100.0 * 100.0;
517 let area = std::f64::consts::FRAC_PI_4 * 0.1 * 0.1;
518 let omega_n = (q * area * 2.0 * (0.6 - 0.3) / 0.05).sqrt();
519 let elapsed = steps as f64 * 0.01;
520 let expected_crossings = omega_n * elapsed / std::f64::consts::PI;
521 let ratio = crossings as f64 / expected_crossings;
522 assert!(
523 (0.75..=1.3).contains(&ratio),
524 "crossings {crossings} vs expected {expected_crossings:.1} (ratio {ratio})"
525 );
526 }
527}