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