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