Skip to main content

hpr_design/
lib.rs

1//! The canonical rocket design model: component tree, shapes, materials, mass properties, stages
2//! and configurations, and design checks.
3//!
4//! **Guide:** [The design tree][guide-design], [Shapes][guide-shapes] and [Mass
5//! properties][guide-mass]: the models, their sources, how well they are validated and what they
6//! leave out.
7//!
8//! [guide-design]: https://nrdptel.github.io/hpr-sim/physics/design.html
9//! [guide-shapes]: https://nrdptel.github.io/hpr-sim/physics/shapes.html
10//! [guide-mass]: https://nrdptel.github.io/hpr-sim/physics/mass.html
11//!
12//! - [`mass`]: mass, center of mass and full inertia tensor, and how bodies combine.
13//! - [`shapes`]: nose cone and transition profiles.
14//! - [`solids`]: solids of revolution, filled or with a wall.
15//! - [`finish`]: surface finishes and their roughness heights.
16//! - [`fins`]: fin sets (trapezoidal, elliptical, freeform) and tube fins.
17//! - [`parts`]: every other component, from body tubes to shock cords.
18//! - [`material`]: materials and their densities, and [`materials`]: built-in values with sources.
19//! - [`tree`]: the design tree of stages and components, placement, automatic radii, overrides and
20//!   the reference diameter.
21//! - [`config`]: motor mounts, configurations, and the rocket's mass properties through the burn.
22//! - [`checks`]: structural checks with typed findings.
23
24pub 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    /// A rocket-like composite: a filled conical nose, a body tube, four rectangular fins and an
70    /// off-axis mass, placed along the body and combined. The expected tensor is written out term
71    /// by term from each part's textbook moments and the parallel-axis theorem, including the
72    /// products of inertia the off-axis mass creates.
73    #[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        // Each part as (mass, center, own diagonal tensor [I_xx, I_yy, I_zz]).
132        let mut parts: Vec<(f64, DVec3, [f64; 3])> = Vec::new();
133        // Cone: I_axis = 3/10 m R², I_across = 3/80 m (4R² + L²), center 3L/4 from the tip.
134        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        // Tube: radii 0.05 and 0.048, center 0.4 m below its forward end at station 0.3.
142        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        // Fins: boxes 0.1 (span) × 0.004 × 0.15, centers at radius R + s/2 = 0.1, station 1.025.
151        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        // Payload: solid cylinder a = 0.02, h = 0.1, at 0.03 m and 30°, center at station 0.55.
167        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        // The off-axis payload makes the products of inertia non-zero.
205        assert!(xy.abs() > 1e-6 && xz.abs() > 1e-6 && yz.abs() > 1e-6);
206        rocket.validate().unwrap();
207    }
208    /// Loft lesson L47: the reference diameter was the widest component, which could be an
209    /// internal one. Here the default is the widest body component; an oversized ring, a mass
210    /// wider than the airframe, fins, tube fins and a shoulder don't count, and the nose-base and
211    /// custom choices do what they say.
212    #[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        // Without the wider booster section, the internal parts still don't count.
273        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        // A bulged secant ogive is wider than its base, and that width counts.
285        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    /// Every public test design in `validation/designs/` (written by `cargo xtask designs`)
293    /// resolves, has no findings, and assembles into a real body at ignition and burnout in every
294    /// configuration.
295    #[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}