1use hpr_aero::{AeroModel, DragConditions, Flow, MOTOR_POD_SETS};
34use hpr_core::attitude::quaternion_derivative;
35use hpr_core::{DMat3, DQuat, DVec3};
36use hpr_design::Assembly;
37
38use crate::environment::Environment;
39use crate::error::SimError;
40use crate::shifts::Shifts;
41use crate::state::{STATE_LEN, State};
42
43const MASS_DERIVATIVE_STEP_S: f64 = 1e-4;
45
46const MIN_AIRSPEED_M_S: f64 = 1e-9;
48
49const MIN_DERIVATIVE_INTERVAL_S: f64 = 2e-5;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum Phase {
58 Pad,
60 Rail,
62 Free,
64 Descent,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
71pub(crate) struct Conditions {
72 pub(crate) phase: Phase,
74 pub(crate) window: (f64, f64),
77 pub(crate) drag_area_m2: f64,
79}
80
81#[derive(Debug, Clone, Copy, Default)]
83struct BurningAreas {
84 airframe_m2: f64,
86 pods_m2: [f64; MOTOR_POD_SETS],
89}
90
91#[derive(Debug, Clone, Copy)]
93struct MotorTerms {
94 nozzle_m: DVec3,
96 exit_radius_m: f64,
98 area_m2: f64,
100 pod_set: Option<usize>,
104 ignition_s: f64,
106 burnout_s: f64,
108}
109
110#[derive(Debug, Clone, Copy)]
112pub(crate) struct MassState {
113 pub(crate) mass_kg: f64,
114 pub(crate) mass_rate_kg_s: f64,
115 pub(crate) cg_m: DVec3,
116 pub(crate) cg_rate_m_s: DVec3,
117 pub(crate) cg_accel_m_s2: DVec3,
118 pub(crate) inertia_cg: DMat3,
119 pub(crate) inertia_o: DMat3,
120 pub(crate) inertia_o_rate: DMat3,
121 pub(crate) relative_momentum: DVec3,
123 pub(crate) relative_momentum_rate: DVec3,
125}
126
127#[derive(Debug, Clone, Copy)]
129pub(crate) struct Evaluation {
130 pub(crate) derivative: [f64; STATE_LEN],
131 pub(crate) mass: MassState,
132 pub(crate) cg_enu_m: DVec3,
133 pub(crate) cg_velocity_enu_m_s: DVec3,
134 pub(crate) height_above_ground_m: f64,
135 pub(crate) vertical_speed_m_s: f64,
136 pub(crate) airspeed_m_s: f64,
137 pub(crate) mach: f64,
138 pub(crate) angle_of_attack_rad: f64,
139 pub(crate) dynamic_pressure_pa: f64,
140 pub(crate) axial_coefficient: f64,
141 pub(crate) thrust_n: f64,
142 pub(crate) rail_force_n: f64,
144 pub(crate) acceleration_enu_m_s2: DVec3,
146 pub(crate) recovery_drag_area_m2: f64,
148}
149
150#[derive(Debug, Clone)]
153pub(crate) struct Vehicle {
154 pub(crate) assembly: Assembly,
155 pub(crate) aero: AeroModel,
156 first_fin_index: usize,
158 motors: Vec<MotorTerms>,
159 ignition_s: Vec<Option<f64>>,
161 reference_area_m2: f64,
162 pub(crate) shifts: Shifts,
164}
165
166impl Vehicle {
167 #[cfg(test)]
169 pub(crate) fn new(assembly: Assembly, aero: AeroModel) -> Result<Self, SimError> {
170 let ignition_s = vec![Some(0.0); assembly.motors.len()];
171 Self::lit(assembly, aero, ignition_s)
172 }
173
174 pub(crate) fn lit(
177 assembly: Assembly,
178 aero: AeroModel,
179 ignition_s: Vec<Option<f64>>,
180 ) -> Result<Self, SimError> {
181 if ignition_s.len() != assembly.motors.len() {
182 return Err(SimError::Domain {
183 what: "count of ignition times (one per motor)",
184 value: ignition_s.len() as f64,
185 });
186 }
187 let motor_pod_sets = assembly.layout.motor_pod_sets();
191 if motor_pod_sets.len() > MOTOR_POD_SETS {
192 return Err(SimError::Domain {
193 what: "count of pod sets holding motor mounts",
194 value: motor_pod_sets.len() as f64,
195 });
196 }
197 let motors = assembly
198 .motors
199 .iter()
200 .zip(&ignition_s)
201 .map(|(placed, ignition)| MotorTerms {
202 nozzle_m: placed.nozzle_m,
203 exit_radius_m: placed
204 .mounted
205 .motor
206 .nozzle()
207 .map_or(0.0, |nozzle| nozzle.exit_radius_m),
208 area_m2: std::f64::consts::PI * (0.5 * placed.mounted.diameter_m).powi(2),
209 pod_set: assembly
211 .layout
212 .find(&placed.mount)
213 .and_then(|(index, _)| assembly.layout.pod_set_of(index))
214 .and_then(|set| motor_pod_sets.iter().position(|&s| s == set)),
215 ignition_s: ignition.unwrap_or(f64::INFINITY),
216 burnout_s: ignition.map_or(f64::INFINITY, |ignition_s| {
217 ignition_s + placed.mounted.motor.burnout_time_s()
218 }),
219 })
220 .collect();
221 let reference_area_m2 = aero.reference_area_m2();
222 let first_fin_index = aero.fin_set_start();
223 Ok(Self {
224 assembly,
225 aero,
226 first_fin_index,
227 motors,
228 ignition_s,
229 reference_area_m2,
230 shifts: Shifts::default(),
231 })
232 }
233
234 pub(crate) fn ignition_s(&self) -> &[Option<f64>] {
236 &self.ignition_s
237 }
238
239 pub(crate) fn burnout_s(&self) -> f64 {
241 self.motors
242 .iter()
243 .map(|m| m.burnout_s)
244 .filter(|t| t.is_finite())
245 .fold(0.0, f64::max)
246 }
247
248 pub(crate) fn thrust_knots_s(&self) -> Vec<f64> {
251 let mut times: Vec<f64> = self
252 .assembly
253 .motors
254 .iter()
255 .zip(&self.ignition_s)
256 .filter_map(|(placed, ignition)| ignition.map(|ignition_s| (placed, ignition_s)))
257 .flat_map(|(placed, ignition_s)| {
258 std::iter::once(ignition_s).chain(
259 placed
260 .mounted
261 .motor
262 .curve()
263 .times_s()
264 .iter()
265 .map(move |t| ignition_s + t),
266 )
267 })
268 .filter(|t| *t > 0.0)
269 .collect();
270 times.sort_by(f64::total_cmp);
271 times.dedup();
272 times
273 }
274
275 pub(crate) fn mass_state(&self, t: f64, window: (f64, f64)) -> MassState {
281 let props = |t: f64| {
282 let mp = self.assembly.mass_properties_lit(t, &self.ignition_s);
283 (
284 mp.mass_kg,
285 mp.cg_m,
286 mp.inertia_kg_m2,
287 mp.inertia_about(DVec3::ZERO),
288 )
289 };
290 let (mass_kg, cg_m, inertia_cg, inertia_o) = props(t);
291 let (a, b) = window;
292 let burning = self
293 .motors
294 .iter()
295 .any(|m| a < m.burnout_s && b > m.ignition_s);
296 let mut state = MassState {
297 mass_kg,
298 mass_rate_kg_s: 0.0,
299 cg_m,
300 cg_rate_m_s: DVec3::ZERO,
301 cg_accel_m_s2: DVec3::ZERO,
302 inertia_cg,
303 inertia_o,
304 inertia_o_rate: DMat3::ZERO,
305 relative_momentum: DVec3::ZERO,
306 relative_momentum_rate: DVec3::ZERO,
307 };
308 let mut mass_second_kg_s2 = 0.0;
309 if burning && b - a >= MIN_DERIVATIVE_INTERVAL_S {
310 let h = MASS_DERIVATIVE_STEP_S.min(0.5 * (b - a));
311 let c = t.max(a + h).min(b - h);
312 let (m_minus, r_minus, _, i_minus) = props(c - h);
313 let (m_plus, r_plus, _, i_plus) = props(c + h);
314 let (m_mid, r_mid) = if c == t {
315 (mass_kg, cg_m)
316 } else {
317 let (m, r, _, _) = props(c);
318 (m, r)
319 };
320 let offset = t - c;
321 let m_second = (m_plus - 2.0 * m_mid + m_minus) / (h * h);
322 state.mass_rate_kg_s = (m_plus - m_minus) / (2.0 * h) + offset * m_second;
323 let r_second = (r_plus - 2.0 * r_mid + r_minus) / (h * h);
324 state.cg_rate_m_s = (r_plus - r_minus) / (2.0 * h) + offset * r_second;
325 state.cg_accel_m_s2 = r_second;
326 state.inertia_o_rate = (i_plus - i_minus) * (0.5 / h);
327 mass_second_kg_s2 = m_second;
328 }
329 if !self.shifts.is_empty() {
330 self.shifts.shift_state(&mut state, mass_second_kg_s2, t);
331 }
332 state
333 }
334
335 pub(crate) fn evaluate(
338 &self,
339 environment: &Environment,
340 friction_coefficient: f64,
341 conditions: Conditions,
342 t: f64,
343 y: &[f64; STATE_LEN],
344 ) -> Result<Evaluation, SimError> {
345 let Conditions {
346 phase,
347 window,
348 drag_area_m2: recovery_drag_area_m2,
349 } = conditions;
350 let state = State::from_array(y);
351 let norm = state.attitude.length();
352 if !(norm.is_finite() && norm > 0.0) {
353 return Err(SimError::Domain {
354 what: "attitude quaternion norm",
355 value: norm,
356 });
357 }
358 let q: DQuat = state.attitude / norm;
359 let to_body = q.conjugate();
360 let mass = self.mass_state(t, window);
361 let m = mass.mass_kg;
362 let r = mass.cg_m;
363 let omega = if phase == Phase::Free {
364 state.body_rate_rad_s
365 } else {
366 DVec3::ZERO
367 };
368
369 let v_o = state.velocity_enu_m_s;
371 let cg_enu_m = state.position_enu_m + q.mul_vec3(r);
372 let cg_velocity_enu_m_s = v_o + q.mul_vec3(omega.cross(r) + mass.cg_rate_m_s);
373 let frame = environment.earth.frame();
374 let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
375 let height_above_ground_m = geodetic.height_m - frame.origin().height_m;
376 let up_ecef = DVec3::new(
377 geodetic.latitude_rad.cos() * geodetic.longitude_rad.cos(),
378 geodetic.latitude_rad.cos() * geodetic.longitude_rad.sin(),
379 geodetic.latitude_rad.sin(),
380 );
381 let up_enu = frame.ecef_from_enu_rotation().transpose() * up_ecef;
382 let vertical_speed_m_s = up_enu.dot(cg_velocity_enu_m_s);
383 let height_msl_m = geodetic.height_m - environment.geoid_undulation_m;
384 let air = environment.air_at(height_msl_m)?;
385 let wind_enu = environment.wind_enu_m_s(height_msl_m)?;
386
387 let gravity_enu = environment.earth.gravity_enu_mps2(cg_enu_m)?;
389 let coriolis_enu = environment
390 .earth
391 .rotation_acceleration_enu_mps2(cg_velocity_enu_m_s);
392 let weight = to_body.mul_vec3((gravity_enu + coriolis_enu) * m);
393
394 let (a, b) = window;
397 let pressure_pa = air.pressure_pa;
398 let mut thrust = DVec3::ZERO;
399 let mut thrust_moment = DVec3::ZERO;
400 let mut t03_jet = DVec3::ZERO;
401 let mut t04_jet = DVec3::ZERO;
402 let mut jet_gyration = DMat3::ZERO;
403 let mut burning = BurningAreas::default();
406 for (terms, placed) in self.motors.iter().zip(&self.assembly.motors) {
407 if a >= terms.burnout_s || b <= terms.ignition_s {
408 continue;
409 }
410 let motor = &placed.mounted.motor;
411 let (a, b) = (a - terms.ignition_s, b - terms.ignition_s);
416 let t = (t - terms.ignition_s)
417 .max(0.0_f64.next_up())
418 .min(motor.burnout_time_s().next_down());
419 let force = DVec3::Z * motor.thrust_at_pressure_n(t, pressure_pa);
420 thrust += force;
421 thrust_moment += terms.nozzle_m.cross(force);
422 match terms.pod_set {
423 None => burning.airframe_m2 += terms.area_m2,
424 Some(set) => burning.pods_m2[set] += terms.area_m2,
425 }
426 let mdot = -motor.state(t).mass_flow_kg_s;
427 let mddot = if b - a < MIN_DERIVATIVE_INTERVAL_S {
428 0.0
429 } else {
430 let h = MASS_DERIVATIVE_STEP_S.min(0.5 * (b - a));
431 let c = t.max(a + h).min(b - h);
432 -(motor.state(c + h).mass_flow_kg_s - motor.state(c - h).mass_flow_kg_s) / (2.0 * h)
433 };
434 let lever = terms.nozzle_m - r;
435 t03_jet += lever * (2.0 * mdot);
436 t04_jet += lever * mddot;
437 let n = terms.nozzle_m;
438 let disc = 0.25 * terms.exit_radius_m * terms.exit_radius_m;
439 let gyration = DMat3::from_diagonal(DVec3::new(disc, disc, 2.0 * disc))
440 + DMat3::from_diagonal(DVec3::splat(n.length_squared()))
441 - outer(n, n);
442 jet_gyration += gyration * mdot;
443 }
444
445 let aero = if phase == Phase::Descent {
447 canopy_drag(
448 &air,
449 to_body.mul_vec3(cg_velocity_enu_m_s - wind_enu),
450 recovery_drag_area_m2,
451 )
452 } else {
453 self.aerodynamics(&air, to_body.mul_vec3(v_o - wind_enu), omega, r, burning)?
454 };
455
456 let forces = aero.force + weight;
458 let t03 = t03_jet - mass.cg_rate_m_s * (2.0 * m);
459 let t04 = thrust - mass.cg_accel_m_s2 * m - mass.cg_rate_m_s * (2.0 * mass.mass_rate_kg_s)
460 + t04_jet;
461 let mut derivative = [0.0; STATE_LEN];
462 let mut acceleration_enu_m_s2 = DVec3::ZERO;
463 let mut rail_force_n = 0.0;
464 match phase {
465 Phase::Free => {
466 let (a_body, omega_dot) = free_motion(
467 &mass,
468 omega,
469 [t03, t04, forces],
470 jet_gyration,
471 [r.cross(weight), aero.moment, thrust_moment],
472 )?;
473 acceleration_enu_m_s2 = q.mul_vec3(a_body);
474 let q_dot = quaternion_derivative(state.attitude, omega);
475 derivative = [
476 v_o.x,
477 v_o.y,
478 v_o.z,
479 acceleration_enu_m_s2.x,
480 acceleration_enu_m_s2.y,
481 acceleration_enu_m_s2.z,
482 q_dot.w,
483 q_dot.x,
484 q_dot.y,
485 q_dot.z,
486 omega_dot.x,
487 omega_dot.y,
488 omega_dot.z,
489 ];
490 }
491 Phase::Descent => {
492 let t20 = t04 + forces;
496 acceleration_enu_m_s2 = q.mul_vec3(t20 / m);
497 derivative[..3].copy_from_slice(&v_o.to_array());
498 derivative[3..6].copy_from_slice(&acceleration_enu_m_s2.to_array());
499 }
500 Phase::Rail | Phase::Pad => {
501 let t20 = t04 + forces;
503 let across = (t20.x * t20.x + t20.y * t20.y).sqrt();
504 rail_force_n = t20.z - friction_coefficient * across;
505 if phase == Phase::Rail {
506 let along = q.mul_vec3(DVec3::Z);
507 acceleration_enu_m_s2 = along * (rail_force_n / m);
508 derivative[..3].copy_from_slice(&v_o.to_array());
509 derivative[3..6].copy_from_slice(&acceleration_enu_m_s2.to_array());
510 }
511 }
512 }
513
514 Ok(Evaluation {
515 derivative,
516 mass,
517 cg_enu_m,
518 cg_velocity_enu_m_s,
519 height_above_ground_m,
520 vertical_speed_m_s,
521 airspeed_m_s: aero.airspeed_m_s,
522 mach: aero.mach,
523 angle_of_attack_rad: aero.angle_of_attack_rad,
524 dynamic_pressure_pa: aero.dynamic_pressure_pa,
525 axial_coefficient: aero.axial_coefficient,
526 thrust_n: thrust.z,
527 rail_force_n,
528 acceleration_enu_m_s2,
529 recovery_drag_area_m2: if phase == Phase::Descent {
530 recovery_drag_area_m2
531 } else {
532 0.0
533 },
534 })
535 }
536
537 fn aerodynamics(
556 &self,
557 air: &hpr_atmos::AirState,
558 air_velocity_o_body: DVec3,
559 omega: DVec3,
560 cg_m: DVec3,
561 burning: BurningAreas,
562 ) -> Result<Aerodynamics, SimError> {
563 let rho = air.density_kg_m3;
564 let sound = air.speed_of_sound_m_s;
565 let area = self.reference_area_m2;
566 let v_cg = air_velocity_o_body + omega.cross(cg_m);
567 let speed = v_cg.length();
568 let mut out = Aerodynamics {
569 airspeed_m_s: speed,
570 mach: speed / sound,
571 ..Aerodynamics::default()
572 };
573 if rho <= 0.0 || speed < MIN_AIRSPEED_M_S {
574 return Ok(out);
575 }
576 let (alpha, roll) = flow_angles(v_cg, speed);
577 out.angle_of_attack_rad = alpha;
578 let q = 0.5 * rho * speed * speed;
579 out.dynamic_pressure_pa = q;
580 let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
581 let conditions = if burning.airframe_m2 > 0.0 || burning.pods_m2.iter().any(|&a| a > 0.0) {
582 DragConditions::thrusting(reynolds_per_m, burning.airframe_m2)
583 .with_pod_motors(burning.pods_m2)
584 } else {
585 DragConditions::coasting(reynolds_per_m)
586 };
587 let flow = Flow::new(out.mach, alpha, roll);
588 let drag = self.aero.drag(&flow, &conditions)?;
589 out.axial_coefficient = drag.axial_coefficient;
590 out.force = DVec3::new(0.0, 0.0, -q * area * drag.axial_coefficient);
591
592 flow.validate()?;
594 let table = self.aero.normal_force_table().is_some();
599 if table {
600 let normal = self.aero.normal_force(&flow)?;
601 let across = DVec3::new(roll.cos(), roll.sin(), 0.0);
602 let side = DVec3::Z.cross(across);
603 out.force += across * (normal.coefficient * q * area);
604 out.moment += side * (-normal.moment_m * q * area);
605 }
606 for index in 0..self.aero.component_count() {
607 let station = self.aero.component_station_m(index, out.mach)?;
611 let p = DVec3::new(0.0, 0.0, -station);
612 let (force, moment) =
613 self.component_force(index, air_velocity_o_body + omega.cross(p), rho, sound)?;
614 out.force += force;
615 out.moment += moment;
616 if table {
617 let (force, moment) = self.component_force(index, v_cg, rho, sound)?;
618 out.force -= force;
619 out.moment -= moment;
620 }
621 }
622 let roll = self.aero.roll(out.mach)?;
628 let d = self.aero.reference_diameter_m();
629 out.moment.z += q * area * d * roll.forcing * alpha.cos()
630 + 0.25 * rho * speed * area * d * d * roll.damping * omega.z;
631 Ok(out)
632 }
633}
634
635impl Vehicle {
636 fn component_force(
639 &self,
640 index: usize,
641 local: DVec3,
642 rho: f64,
643 sound: f64,
644 ) -> Result<(DVec3, DVec3), SimError> {
645 let local_speed = local.length();
646 if local_speed < MIN_AIRSPEED_M_S {
647 return Ok((DVec3::ZERO, DVec3::ZERO));
648 }
649 let (alpha_i, roll_i) = flow_angles(local, local_speed);
650 let normal = self
651 .aero
652 .component_normal_force(index, &Flow::new(local_speed / sound, alpha_i, roll_i))?;
653 let fin_scale = if index >= self.first_fin_index && alpha_i > 0.0 {
656 alpha_i.sin() / alpha_i
657 } else {
658 1.0
659 };
660 let q_i = 0.5 * rho * local_speed * local_speed * self.reference_area_m2 * fin_scale;
661 let across = DVec3::new(roll_i.cos(), roll_i.sin(), 0.0);
662 let side = DVec3::Z.cross(across);
663 Ok((
664 (across * normal.coefficient + side * normal.side_coefficient) * q_i,
665 (side * -normal.moment_m + across * normal.side_moment_m) * q_i,
666 ))
667 }
668}
669
670#[derive(Debug, Clone, Copy, Default)]
672struct Aerodynamics {
673 force: DVec3,
674 moment: DVec3,
675 airspeed_m_s: f64,
676 mach: f64,
677 angle_of_attack_rad: f64,
678 dynamic_pressure_pa: f64,
679 axial_coefficient: f64,
680}
681
682fn free_motion(
687 mass: &MassState,
688 omega: DVec3,
689 [t03, t04, forces]: [DVec3; 3],
690 jet_gyration: DMat3,
691 [weight_moment, aero_moment, thrust_moment]: [DVec3; 3],
692) -> Result<(DVec3, DVec3), SimError> {
693 let (m, r) = (mass.mass_kg, mass.cg_m);
694 let t20 = -omega.cross(omega.cross(r * m)) + omega.cross(t03) + t04 + forces;
695 let mut t21 = -omega.cross(mass.inertia_o * omega)
696 + (jet_gyration - mass.inertia_o_rate) * omega
697 + weight_moment
698 + aero_moment
699 + thrust_moment;
700 if mass.relative_momentum != DVec3::ZERO || mass.relative_momentum_rate != DVec3::ZERO {
701 t21 -= omega.cross(mass.relative_momentum) + mass.relative_momentum_rate;
702 }
703 let determinant = mass.inertia_cg.determinant();
704 if !(determinant.is_finite() && determinant > 0.0) {
705 return Err(SimError::Domain {
706 what: "determinant of the inertia about the center of mass",
707 value: determinant,
708 });
709 }
710 let omega_dot = mass.inertia_cg.inverse() * (t21 - r.cross(t20));
711 Ok((t20 / m - omega_dot.cross(r), omega_dot))
712}
713
714fn canopy_drag(
721 air: &hpr_atmos::AirState,
722 air_velocity_cg_body: DVec3,
723 drag_area_m2: f64,
724) -> Aerodynamics {
725 let speed = air_velocity_cg_body.length();
726 let rho = air.density_kg_m3;
727 let mut out = Aerodynamics {
728 airspeed_m_s: speed,
729 mach: speed / air.speed_of_sound_m_s,
730 ..Aerodynamics::default()
731 };
732 if rho <= 0.0 || speed < MIN_AIRSPEED_M_S || drag_area_m2 <= 0.0 {
733 return out;
734 }
735 let (alpha, _) = flow_angles(air_velocity_cg_body, speed);
736 out.angle_of_attack_rad = alpha;
737 out.dynamic_pressure_pa = 0.5 * rho * speed * speed;
738 out.force = air_velocity_cg_body * (-0.5 * rho * drag_area_m2 * speed);
739 out
740}
741
742fn flow_angles(v: DVec3, _speed: f64) -> (f64, f64) {
746 let alpha = v.x.hypot(v.y).atan2(v.z);
748 let roll = if v.x == 0.0 && v.y == 0.0 {
749 0.0
750 } else {
751 (-v.y).atan2(-v.x)
752 };
753 (alpha, roll)
754}
755
756fn outer(u: DVec3, v: DVec3) -> DMat3 {
758 DMat3::from_cols(u * v.x, u * v.y, u * v.z)
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::testing::{UniformAir, analytic_environment, design};
765
766 fn valetudo() -> Vehicle {
767 let assembly = design("rocketpy-valetudo").assemble("example").unwrap();
768 let aero = AeroModel::new(&assembly.layout).unwrap();
769 Vehicle::new(assembly, aero).unwrap()
770 }
771
772 #[test]
775 fn a_pod_s_motors_burn_into_the_pod_s_base() {
776 let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
777 let material =
778 serde_json::json!({ "name": "test", "density": { "kind": "bulk", "kg_m3": 1000.0 } });
779 let pods = serde_json::json!({
780 "id": "pods",
781 "part": { "pod_set": { "count": 2, "radial_offset_m": 0.1, "angle_rad": 0.0 } },
782 "position": { "from": "top", "aft_offset_m": 0.8 },
783 "children": [{
784 "id": "pod-tube",
785 "part": { "body_tube": {
786 "length_m": 0.9, "outer_radius_m": 0.02, "thickness_m": 0.002,
787 "material": material } },
788 "motor_mount": { "overhang_m": 0.0 }
789 }]
790 });
791 rocket["stages"][0]["components"][1]["children"]
792 .as_array_mut()
793 .unwrap()
794 .push(pods);
795 let mut pod_motor = rocket["configurations"][0]["motors"][0].clone();
796 pod_motor["mount"] = serde_json::json!("pod-tube");
797 rocket["configurations"][0]["motors"]
798 .as_array_mut()
799 .unwrap()
800 .push(pod_motor);
801 let rocket: hpr_design::Rocket = serde_json::from_value(rocket).unwrap();
802 let assembly = rocket.assemble("example").unwrap();
803 let aero = AeroModel::new(&assembly.layout).unwrap();
804 let vehicle = Vehicle::new(assembly, aero).unwrap();
805 let pod_sets: Vec<_> = vehicle.motors.iter().map(|m| m.pod_set).collect();
806 assert_eq!(pod_sets, [None, Some(0), Some(0)]);
807 let area = vehicle.motors[1].area_m2;
808 assert!(area > 0.0 && vehicle.motors[2].area_m2 == area);
809
810 let air = UniformAir::sea_level().0;
815 let speed = 50.0;
816 let v = DVec3::new(0.0, 0.0, speed);
817 let cg = vehicle.assembly.mass_properties(0.0).cg_m;
818 let axial = |[airframe_m2, pods_m2]: [f64; 2]| {
819 let areas = BurningAreas {
820 airframe_m2,
821 pods_m2: [pods_m2, 0.0, 0.0, 0.0],
822 };
823 vehicle
824 .aerodynamics(&air, v, DVec3::ZERO, cg, areas)
825 .unwrap()
826 .axial_coefficient
827 };
828 let flow = Flow::new(speed / air.speed_of_sound_m_s, 0.0, 0.0);
829 let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
830 let pods = DragConditions::thrusting(reynolds_per_m, 0.0).with_pod_motors([
831 2.0 * area,
832 0.0,
833 0.0,
834 0.0,
835 ]);
836 let expected = vehicle.aero.drag(&flow, &pods).unwrap().axial_coefficient;
837 assert_eq!(axial([0.0, 2.0 * area]), expected);
838 assert!(std::f64::consts::PI * 0.02_f64.powi(2) < area);
839 assert!((axial([2.0 * area, 0.0]) - expected).abs() > 1e-3);
840 }
841
842 #[test]
847 fn two_pod_sets_motors_burn_into_their_own_bases() {
848 let material =
849 serde_json::json!({ "name": "test", "density": { "kind": "bulk", "kg_m3": 1000.0 } });
850 let pod_set = |id: &str, count: u32, radius_m: f64, angle_rad: f64| {
851 serde_json::json!({
852 "id": id,
853 "part": { "pod_set": {
854 "count": count, "radial_offset_m": 0.15, "angle_rad": angle_rad } },
855 "position": { "from": "top", "aft_offset_m": 0.8 },
856 "children": [{
857 "id": format!("{id}-tube"),
858 "part": { "body_tube": {
859 "length_m": 0.9, "outer_radius_m": radius_m, "thickness_m": 0.002,
860 "material": material } },
861 "motor_mount": { "overhang_m": 0.0 }
862 }]
863 })
864 };
865 let with_sets = |sets: &[(&str, u32, f64)]| {
866 let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
867 for (k, &(id, count, radius_m)) in sets.iter().enumerate() {
868 rocket["stages"][0]["components"][1]["children"]
869 .as_array_mut()
870 .unwrap()
871 .push(pod_set(id, count, radius_m, 0.3 * k as f64));
872 let mut motor = rocket["configurations"][0]["motors"][0].clone();
873 motor["mount"] = serde_json::json!(format!("{id}-tube"));
874 rocket["configurations"][0]["motors"]
875 .as_array_mut()
876 .unwrap()
877 .push(motor);
878 }
879 serde_json::from_value::<hpr_design::Rocket>(rocket).unwrap()
880 };
881 let rocket = with_sets(&[("pair", 2, 0.02), ("single", 1, 0.06)]);
882 let assembly = rocket.assemble("example").unwrap();
883 let aero = AeroModel::new(&assembly.layout).unwrap();
884 let vehicle = Vehicle::new(assembly, aero).unwrap();
885 let pod_sets: Vec<_> = vehicle.motors.iter().map(|m| m.pod_set).collect();
886 assert_eq!(pod_sets, [None, Some(0), Some(0), Some(1)]);
887 let area = vehicle.motors[1].area_m2;
888 assert!(std::f64::consts::PI * 0.02_f64.powi(2) < area);
889 assert!(area < std::f64::consts::PI * 0.06_f64.powi(2) - area);
890
891 let air = UniformAir::sea_level().0;
892 let speed = 50.0;
893 let v = DVec3::new(0.0, 0.0, speed);
894 let cg = vehicle.assembly.mass_properties(0.0).cg_m;
895 let axial = |pods_m2: [f64; MOTOR_POD_SETS]| {
896 let areas = BurningAreas {
897 airframe_m2: 0.0,
898 pods_m2,
899 };
900 vehicle
901 .aerodynamics(&air, v, DVec3::ZERO, cg, areas)
902 .unwrap()
903 .axial_coefficient
904 };
905 let flow = Flow::new(speed / air.speed_of_sound_m_s, 0.0, 0.0);
906 let reynolds_per_m = speed / air.kinematic_viscosity_m2_s();
907 let split = [2.0 * area, area, 0.0, 0.0];
908 let conditions = DragConditions::thrusting(reynolds_per_m, 0.0).with_pod_motors(split);
909 let expected = vehicle
910 .aero
911 .drag(&flow, &conditions)
912 .unwrap()
913 .axial_coefficient;
914 assert_eq!(axial(split), expected);
915 assert!(axial([3.0 * area, 0.0, 0.0, 0.0]) - expected > 1e-4);
918 assert!((axial([0.0, 3.0 * area, 0.0, 0.0]) - expected).abs() > 1e-4);
919 assert!(axial([2.0 * area, 0.0, 0.0, 0.0]) > expected);
922 assert_eq!(axial([5.0 * area, area, 0.0, 0.0]), expected);
923
924 let five: Vec<(String, u32, f64)> = (0..5).map(|k| (format!("set-{k}"), 1, 0.06)).collect();
925 let five: Vec<(&str, u32, f64)> = five
926 .iter()
927 .map(|(id, n, r)| (id.as_str(), *n, *r))
928 .collect();
929 let layout = with_sets(&five).assemble("example").unwrap().layout;
930 match AeroModel::new(&layout) {
931 Err(hpr_aero::AeroError::Unsupported(why)) => {
932 assert!(why.contains("motor mounts in 5 pod sets"), "{why}");
933 }
934 other => panic!("not refused: {other:?}"),
935 }
936 }
937
938 #[test]
939 fn a_supersonic_flow_takes_the_body_s_terms_at_its_mach_number() {
940 let vehicle = valetudo();
945 let aero = &vehicle.aero;
946 let covered = aero
947 .supersonic_body()
948 .expect("Valetudo's ogive nose is pointed")
949 .covered;
950 let air = UniformAir::sea_level().0;
951 let alpha: f64 = 0.02;
952 let cg = vehicle.assembly.mass_properties(10.0).cg_m;
953 let normal = |mach: f64| {
954 let speed = mach * air.speed_of_sound_m_s;
955 let v = DVec3::new(alpha.sin(), 0.0, alpha.cos()) * speed;
956 let out = vehicle
957 .aerodynamics(&air, v, DVec3::ZERO, cg, BurningAreas::default())
958 .unwrap();
959 let q_area = 0.5 * air.density_kg_m3 * speed * speed * vehicle.reference_area_m2;
960 let (a, roll) = flow_angles(v, speed);
961 let flow = Flow::new(mach, a, roll);
962 let (mut force, mut moment, mut body) = (0.0, 0.0, 0.0);
963 for index in 0..aero.component_count() {
964 let n = aero.component_normal_force(index, &flow).unwrap();
965 let scale = if index >= vehicle.first_fin_index {
966 a.sin() / a
967 } else {
968 body += n.coefficient * f64::from(u8::from(index < covered));
969 1.0
970 };
971 force += n.coefficient * scale;
972 moment += n.moment_m * scale;
973 }
974 let got = (
975 out.force.truncate().length() / q_area,
976 out.moment.truncate().length() / q_area,
977 );
978 (got, (force, moment), body)
979 };
980 for mach in [1.0, 2.0, 3.0] {
981 let (got, want, _) = normal(mach);
982 assert!(
983 (got.0 - want.0).abs() <= 1e-12 * want.0,
984 "Mach {mach}: {got:?} {want:?}"
985 );
986 assert!(
987 (got.1 - want.1).abs() <= 1e-12 * want.1,
988 "Mach {mach}: {got:?} {want:?}"
989 );
990 }
991 let (_, _, slender) = normal(1.0);
993 let (_, _, supersonic) = normal(2.0);
994 assert!(supersonic > 1.1 * slender, "{supersonic} vs {slender}");
995 }
996
997 #[test]
998 fn mass_rates_match_the_motor_and_stay_inside_the_interval() {
999 let vehicle = valetudo();
1002 let motor = &vehicle.assembly.motors[0].mounted.motor;
1003 for (t, window) in [
1004 (1.5, (1.4, 1.6)),
1005 (1.5, (1.0, 1.50001)),
1006 (0.00002, (0.0, 0.1)),
1007 ] {
1008 let state = vehicle.mass_state(t, window);
1009 let expected = -motor.state(t).mass_flow_kg_s;
1010 assert!(
1011 (state.mass_rate_kg_s - expected).abs() <= 1e-6 * expected.abs(),
1012 "t {t}: {} vs {expected}",
1013 state.mass_rate_kg_s
1014 );
1015 }
1016 let coasting = vehicle.mass_state(10.0, (9.0, 11.0));
1017 assert_eq!(coasting.mass_rate_kg_s, 0.0);
1018 assert_eq!(coasting.inertia_o_rate, DMat3::ZERO);
1019 }
1020
1021 #[test]
1022 fn rail_friction_is_coulomb_on_the_force_across_the_rail() {
1023 let vehicle = valetudo();
1027 let environment = analytic_environment(UniformAir::vacuum(), 9.806_65);
1028 let elevation = std::f64::consts::FRAC_PI_3;
1029 let state = State {
1030 position_enu_m: DVec3::new(0.0, 0.0, 10.0),
1031 velocity_enu_m_s: DVec3::ZERO,
1032 attitude: crate::rail::Rail {
1033 elevation_rad: elevation,
1034 ..crate::rail::Rail::vertical(3.0)
1035 }
1036 .attitude(),
1037 body_rate_rad_s: DVec3::ZERO,
1038 };
1039 let along = |mu: f64| {
1040 vehicle
1041 .evaluate(
1042 &environment,
1043 mu,
1044 Conditions {
1045 phase: Phase::Rail,
1046 window: (1.0, 2.0),
1047 drag_area_m2: 0.0,
1048 },
1049 1.5,
1050 &state.to_array(),
1051 )
1052 .unwrap()
1053 };
1054 let (free, rubbing) = (along(0.0), along(0.3));
1055 let mass = vehicle.mass_state(1.5, (1.0, 2.0)).mass_kg;
1056 let expected = 0.3 * mass * 9.806_65 * elevation.cos();
1057 assert!((free.rail_force_n - rubbing.rail_force_n - expected).abs() < 1e-9 * expected);
1058 let direction = state.unit_attitude().mul_vec3(DVec3::Z);
1059 let difference = free.acceleration_enu_m_s2 - rubbing.acceleration_enu_m_s2;
1060 assert!((difference - direction * (expected / mass)).length() < 1e-12);
1061 }
1062
1063 #[test]
1064 fn jet_damping_matches_the_classical_form() {
1065 let vehicle = valetudo();
1070 let environment = analytic_environment(UniformAir::vacuum(), 0.0);
1071 let placed = &vehicle.assembly.motors[0];
1072 let motor = &placed.mounted.motor;
1073 let exit_radius = motor.nozzle().map_or(0.0, |n| n.exit_radius_m);
1074 assert!(exit_radius > 0.0);
1075 let t = 1.5;
1076 let window = (1.0, 2.0);
1077 let rate = 0.2;
1078 let state = State {
1079 position_enu_m: DVec3::new(0.0, 0.0, 1000.0),
1080 velocity_enu_m_s: DVec3::new(0.0, 0.0, 50.0),
1081 attitude: DQuat::IDENTITY,
1082 body_rate_rad_s: DVec3::new(0.0, rate, 0.0),
1083 };
1084 let evaluation = vehicle
1085 .evaluate(
1086 &environment,
1087 0.0,
1088 Conditions {
1089 phase: Phase::Free,
1090 window,
1091 drag_area_m2: 0.0,
1092 },
1093 t,
1094 &state.to_array(),
1095 )
1096 .unwrap();
1097 let omega_dot = DVec3::from_slice(&evaluation.derivative[10..13]);
1098
1099 let props = vehicle.assembly.mass_properties(t);
1100 let h = 1e-4;
1101 let inertia_rate = (vehicle
1102 .assembly
1103 .mass_properties(t + h)
1104 .inertia_kg_m2
1105 .y_axis
1106 .y
1107 - vehicle
1108 .assembly
1109 .mass_properties(t - h)
1110 .inertia_kg_m2
1111 .y_axis
1112 .y)
1113 / (2.0 * h);
1114 let mdot = -motor.state(t).mass_flow_kg_s;
1115 let lever = placed.nozzle_m.z - props.cg_m.z;
1116 let inertia = props.inertia_kg_m2.y_axis.y;
1117 let expected = (mdot * (0.25 * exit_radius * exit_radius + lever * lever) - inertia_rate)
1118 * rate
1119 / inertia;
1120 assert!(expected < 0.0, "jet damping must damp: {expected}");
1121 assert!(
1122 (omega_dot.y - expected).abs() <= 1e-6 * expected.abs(),
1123 "{} vs {expected}",
1124 omega_dot.y
1125 );
1126 assert!(omega_dot.x.abs() + omega_dot.z.abs() < 1e-12);
1127 }
1128
1129 #[test]
1130 fn flow_angles_follow_the_frames_conventions() {
1131 let (alpha, _) = flow_angles(DVec3::Z, 1.0);
1133 assert_eq!(alpha, 0.0);
1134 let v = DVec3::new(1.0, 0.0, 1.0);
1136 let (alpha, roll) = flow_angles(v, v.length());
1137 assert!((alpha - std::f64::consts::FRAC_PI_4).abs() < 1e-15);
1138 assert!((roll.abs() - std::f64::consts::PI).abs() < 1e-15);
1139 let (alpha, roll) = flow_angles(-DVec3::Y, 1.0);
1141 assert!((alpha - std::f64::consts::FRAC_PI_2).abs() < 1e-15);
1142 assert!((roll - std::f64::consts::FRAC_PI_2).abs() < 1e-15);
1143 let (alpha, _) = flow_angles(-DVec3::Z, 1.0);
1145 assert!((alpha - std::f64::consts::PI).abs() < 1e-15);
1146 }
1147
1148 #[test]
1149 fn outer_product_is_u_v_transpose() {
1150 let m = outer(DVec3::new(1.0, 2.0, 3.0), DVec3::new(4.0, 5.0, 6.0));
1151 assert_eq!(m.row(1), DVec3::new(8.0, 10.0, 12.0));
1152 assert_eq!(m.col(2), DVec3::new(6.0, 12.0, 18.0));
1153 }
1154}