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