1pub mod checks;
25pub mod config;
26pub mod error;
27pub mod finish;
28pub mod fins;
29pub mod mass;
30pub mod material;
31pub mod materials;
32pub mod parts;
33pub mod shapes;
34pub mod solids;
35pub mod tree;
36
37#[cfg(test)]
38mod testing;
39
40pub use checks::{Finding, Severity};
41pub use config::{
42 Assembly, Configuration, Ignition, LaidOut, MotorMount, MountedMotor, PlacedMotor,
43};
44pub use error::DesignError;
45pub use finish::Finish;
46pub use fins::{FinCrossSection, FinFillet, FinPlanform, FinSet, FinTab, TubeFinSet};
47pub use mass::{MassProperties, Placement};
48pub use material::{Density, Material};
49pub use parts::{
50 BodyTube, CenteringRing, InnerTube, LaunchLug, MAX_INSTANCES, MassComponent, NoseCone, Packing,
51 Parachute, PodSet, RailButton, ShockCord, Shoulder, Streamer, Transition,
52};
53pub use shapes::{NoseShape, Profile};
54pub use solids::{RevolvedGeometry, Wall, revolve};
55pub use tree::{
56 AutoDimension, Component, DragOverride, InertiaOverride, Layout, Overrides, ParallelStage,
57 Part, PlacedComponent, PlacedStage, Position, ReferenceDiameter, Rocket, Stage,
58 UnresolvableRadius,
59};
60
61#[cfg(test)]
62mod tests {
63 use std::f64::consts::PI;
64
65 use hpr_core::{DMat3, DVec3};
66
67 use super::*;
68
69 #[test]
74 fn composite_rocket_inertia_matches_hand_calculation() {
75 let r = 0.05;
76 let nose = NoseCone {
77 shape: NoseShape::Conical {},
78 length_m: 0.3,
79 base_radius_m: r,
80 wall: Wall::Filled {},
81 shoulder: None,
82 material: Material::bulk("PLA", 1240.0),
83 };
84 let tube = BodyTube {
85 length_m: 0.8,
86 outer_radius_m: r,
87 thickness_m: 0.002,
88 material: Material::bulk("cardboard", 790.0),
89 };
90 let fins = FinSet {
91 count: 4,
92 planform: FinPlanform::Trapezoidal {
93 root_chord_m: 0.15,
94 tip_chord_m: 0.15,
95 span_m: 0.1,
96 sweep_m: 0.0,
97 },
98 thickness_m: 0.004,
99 cross_section: FinCrossSection::Square,
100 tab: None,
101 fillet: None,
102 cant_rad: 0.0,
103 base_angle_rad: 0.0,
104 material: Material::bulk("plywood", 630.0),
105 };
106 let angle = 30f64.to_radians();
107 let payload = MassComponent {
108 mass_kg: 0.3,
109 packing: Packing {
110 length_m: 0.1,
111 radius_m: 0.02,
112 radial_offset_m: 0.03,
113 angle_rad: angle,
114 },
115 };
116 let placed = [
117 nose.mass_properties().unwrap(),
118 tube.mass_properties()
119 .unwrap()
120 .translated(DVec3::new(0.0, 0.0, -0.3)),
121 fins.mass_properties(r)
122 .unwrap()
123 .translated(DVec3::new(0.0, 0.0, -0.95)),
124 payload
125 .mass_properties()
126 .unwrap()
127 .translated(DVec3::new(0.0, 0.0, -0.5)),
128 ];
129 let rocket = MassProperties::combine(&placed);
130
131 let mut parts: Vec<(f64, DVec3, [f64; 3])> = Vec::new();
133 let m = 1240.0 * PI * r * r * 0.3 / 3.0;
135 let across = 3.0 / 80.0 * m * (4.0 * r * r + 0.09);
136 parts.push((
137 m,
138 DVec3::new(0.0, 0.0, -0.225),
139 [across, across, 0.3 * m * r * r],
140 ));
141 let radii = r * r + 0.048 * 0.048;
143 let m = 790.0 * PI * (r * r - 0.048 * 0.048) * 0.8;
144 let across = m * (radii / 4.0 + 0.64 / 12.0);
145 parts.push((
146 m,
147 DVec3::new(0.0, 0.0, -0.7),
148 [across, across, m * radii / 2.0],
149 ));
150 let m = 630.0 * 0.15 * 0.1 * 0.004;
152 let (s2, t2, c2) = (0.01, 0.004f64.powi(2), 0.0225);
153 for (x, y) in [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] {
154 let span_along_x = x != 0.0;
155 let (ixx, iyy) = if span_along_x {
156 (m * (t2 + c2) / 12.0, m * (s2 + c2) / 12.0)
157 } else {
158 (m * (s2 + c2) / 12.0, m * (t2 + c2) / 12.0)
159 };
160 parts.push((
161 m,
162 DVec3::new(0.1 * x, 0.1 * y, -1.025),
163 [ixx, iyy, m * (s2 + t2) / 12.0],
164 ));
165 }
166 let across = 0.3 * (3.0 * 0.0004 + 0.01) / 12.0;
168 parts.push((
169 0.3,
170 DVec3::new(0.03 * angle.cos(), 0.03 * angle.sin(), -0.55),
171 [across, across, 0.3 * 0.0004 / 2.0],
172 ));
173
174 let total: f64 = parts.iter().map(|p| p.0).sum();
175 let center = parts.iter().fold(DVec3::ZERO, |sum, p| sum + p.1 * p.0) / total;
176 let (mut xx, mut yy, mut zz, mut xy, mut xz, mut yz) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
177 for (m, c, own) in &parts {
178 let d = *c - center;
179 xx += own[0] + m * (d.y * d.y + d.z * d.z);
180 yy += own[1] + m * (d.x * d.x + d.z * d.z);
181 zz += own[2] + m * (d.x * d.x + d.y * d.y);
182 xy -= m * d.x * d.y;
183 xz -= m * d.x * d.z;
184 yz -= m * d.y * d.z;
185 }
186 let expected = DMat3::from_cols(
187 DVec3::new(xx, xy, xz),
188 DVec3::new(xy, yy, yz),
189 DVec3::new(xz, yz, zz),
190 );
191
192 assert!((rocket.mass_kg - total).abs() < 1e-12 * total);
193 assert!((rocket.cg_m - center).length() < 1e-12);
194 let scale = xx.max(yy).max(zz);
195 let diff = (rocket.inertia_kg_m2 - expected)
196 .to_cols_array()
197 .iter()
198 .fold(0.0f64, |acc, v| acc.max(v.abs()));
199 assert!(
200 diff < 1e-11 * scale,
201 "{:?}\nvs\n{expected:?}",
202 rocket.inertia_kg_m2
203 );
204 assert!(xy.abs() > 1e-6 && xz.abs() > 1e-6 && yz.abs() > 1e-6);
206 rocket.validate().unwrap();
207 }
208 #[test]
213 fn reference_diameter_ignores_internal_components() {
214 use crate::testing::{
215 attached, body, fins, mass_component, nose, ring, rocket, stage, top, tube,
216 };
217
218 let mut upper = body("upper", tube(0.5, 0.03, 0.001));
219 upper.children = vec![
220 attached("oversized-ring", ring(0.005, 0.07, 0.0), top(0.1)),
221 attached("wide-mass", mass_component(0.2, 0.05, 0.09), top(0.2)),
222 attached("fins", fins(0.1, 0.2), top(0.3)),
223 attached(
224 "tube-fins",
225 Part::TubeFinSet(TubeFinSet {
226 count: 6,
227 length_m: 0.1,
228 outer_radius_m: 0.03,
229 thickness_m: 0.001,
230 base_angle_rad: 0.0,
231 material: Material::bulk("cardboard", 790.0),
232 }),
233 top(0.35),
234 ),
235 ];
236 let mut nose_cone = body("nose", nose(0.2, 0.03));
237 if let Part::NoseCone(n) = &mut nose_cone.part {
238 n.shoulder = Some(Shoulder {
239 length_m: 0.05,
240 outer_radius_m: 0.08,
241 thickness_m: 0.002,
242 capped: false,
243 });
244 }
245 let lower = body(
246 "flare",
247 Part::Transition(Transition {
248 shape: NoseShape::Conical {},
249 clipped: false,
250 length_m: 0.1,
251 fore_radius_m: 0.03,
252 aft_radius_m: 0.04,
253 wall: Wall::Shell { thickness_m: 0.002 },
254 fore_shoulder: None,
255 aft_shoulder: None,
256 material: Material::bulk("cardboard", 790.0),
257 }),
258 );
259 let mut design = rocket(vec![stage(
260 "s",
261 vec![
262 nose_cone,
263 upper,
264 lower,
265 body("booster", tube(0.4, 0.04, 0.001)),
266 ],
267 )]);
268 let layout = design.layout().unwrap();
269 assert_eq!(layout.reference_diameter_m, 0.08);
270 assert!((layout.reference_area_m2() - PI * 0.0016).abs() < 1e-15);
271
272 let mut short = design.clone();
274 short.stages[0].components.truncate(2);
275 assert_eq!(short.layout().unwrap().reference_diameter_m, 0.06);
276
277 design.reference_diameter = ReferenceDiameter::NoseBase {};
278 assert_eq!(design.layout().unwrap().reference_diameter_m, 0.06);
279 design.reference_diameter = ReferenceDiameter::Custom { diameter_m: 0.1 };
280 assert_eq!(design.layout().unwrap().reference_diameter_m, 0.1);
281 design.reference_diameter = ReferenceDiameter::Custom { diameter_m: 0.0 };
282 assert!(matches!(design.layout(), Err(DesignError::Domain { .. })));
283
284 let mut bulged = short;
286 if let Part::NoseCone(n) = &mut bulged.stages[0].components[0].part {
287 n.shape = NoseShape::Ogive { radius_ratio: 0.5 };
288 }
289 let d = bulged.layout().unwrap().reference_diameter_m;
290 assert!(d > 0.06 + 1e-4, "{d}");
291 }
292 #[test]
296 fn validation_designs_resolve_and_pass_checks() {
297 const DESIGNS: [(&str, &str); 9] = [
298 (
299 "rocketpy-calisto-getting-started-motor-at-minus-1.255",
300 include_str!(
301 "../../../validation/designs/rocketpy-calisto-getting-started-motor-at-minus-1.255.json"
302 ),
303 ),
304 (
305 "rocketpy-calisto-tests-motor-at-minus-1.373",
306 include_str!(
307 "../../../validation/designs/rocketpy-calisto-tests-motor-at-minus-1.373.json"
308 ),
309 ),
310 (
311 "rocketpy-bella-lui",
312 include_str!("../../../validation/designs/rocketpy-bella-lui.json"),
313 ),
314 (
315 "rocketpy-ndrt-2020-nose-to-tail",
316 include_str!("../../../validation/designs/rocketpy-ndrt-2020-nose-to-tail.json"),
317 ),
318 (
319 "rocketpy-valetudo",
320 include_str!("../../../validation/designs/rocketpy-valetudo.json"),
321 ),
322 (
323 "rocketpy-juno-iii",
324 include_str!("../../../validation/designs/rocketpy-juno-iii.json"),
325 ),
326 (
327 "rocketpy-prometheus-2022-generic-motor",
328 include_str!(
329 "../../../validation/designs/rocketpy-prometheus-2022-generic-motor.json"
330 ),
331 ),
332 (
333 "synthetic-54mm-three-fin",
334 include_str!("../../../validation/designs/synthetic-54mm-three-fin.json"),
335 ),
336 (
337 "synthetic-two-stage-75mm-54mm",
338 include_str!("../../../validation/designs/synthetic-two-stage-75mm-54mm.json"),
339 ),
340 ];
341 for (name, text) in DESIGNS {
342 let design: Rocket = serde_json::from_str(text).unwrap();
343 let findings = checks::check(&design).unwrap();
344 println!("{name}: {findings:?}");
345 assert!(findings.is_empty(), "{name}: {findings:?}");
346 let layout = design.layout().unwrap();
347 println!(
348 " structure {:.4} kg, center at station {:.4} m, length {:.4} m, reference {:.4} m",
349 layout.structure.mass_kg,
350 -layout.structure.cg_m.z,
351 layout.length_m,
352 layout.reference_diameter_m
353 );
354 assert!(layout.reference_diameter_m > 0.0);
355 assert!(!design.configurations.is_empty(), "{name}");
356 for configuration in &design.configurations {
357 let assembly = design.assemble(&configuration.id).unwrap();
358 let burnout = assembly
359 .motors
360 .iter()
361 .map(|m| m.mounted.motor.burnout_time_s())
362 .fold(0.0, f64::max);
363 for t in [0.0, 0.5 * burnout, burnout] {
364 let whole = assembly.mass_properties(t);
365 whole.validate().unwrap();
366 assert!(whole.mass_kg > layout.structure.mass_kg, "{name}");
367 }
368 }
369 }
370 }
371}