hpr/rocket/mod.rs
1//! A rocket built part by part, from the nose back.
2//!
3//! [`Rocket::new`] starts an empty rocket of one body diameter. Body parts ([`Nose`], [`Tube`],
4//! [`Transition`]) stack from the nose tip in the order they are added. Attached parts ([`Fins`],
5//! [`MotorTube`], [`Mass`]) go on the last body tube added, where their [`Position`] puts them.
6//! [`Fitting`]s (couplers, centering rings, bulkheads, launch lugs, packed parachutes and
7//! streamers) go there too. [`Rocket::set_motor`] puts a [`Motor`] in the motor tube, and
8//! [`Rocket::add_parachute`] adds a recovery device. What the builder makes is an ordinary
9//! [`hpr_design::Rocket`], the same tree a design file holds ([`Rocket::design`]); a design you
10//! already have flies through [`Rocket::from_design`].
11//!
12//! Every part names its material. [`material`] finds a built-in one by id; the list, with each
13//! density's source, is [`hpr_design::materials`]. The builder has no default materials or wall
14//! thicknesses, because each one would be a guess at your rocket's mass. Every outer surface has
15//! the design's default finish, mass-production paint ([`hpr_design::Finish`]), which sets its
16//! skin friction; the builder can't change it yet.
17//!
18//! Parts can also come from a parts catalog, such as the one OpenRocket ships
19//! ([`hpr_io::orc::bundled`]): [`Nose::from_catalog`], [`Tube::from_catalog`],
20//! [`Transition::from_catalog`], [`MotorTube::from_catalog`] and [`Fitting::from_catalog`] make
21//! each part as the catalog gives it, its material and stated mass included. What a catalog
22//! leaves unsaid is chosen as OpenRocket chooses it, but for a hollow part's shoulder wall
23//! ([`catalog`] lists each choice).
24
25use hpr_aero::AeroModel;
26use hpr_design::checks::{check, has_errors};
27use hpr_design::{
28 Assembly, AutoDimension, BodyTube, Component, Configuration, FinCrossSection, FinPlanform,
29 FinSet, Ignition, InnerTube, MassComponent, MassProperties, Material, MotorMount, MountedMotor,
30 NoseCone, NoseShape, Overrides, Packing, Part, Position, Profile, ReferenceDiameter, Shoulder,
31 Stage, Wall, materials,
32};
33use hpr_sim::Device;
34use hpr_sim::metrics::{self, Margin};
35use serde::{Deserialize, Serialize};
36
37use crate::error::{Error, Order, finite, non_negative, positive};
38use crate::motor::Motor;
39
40pub mod catalog;
41
42pub use catalog::Fitting;
43
44/// The built-in material with id `id`, such as `"abs"`, `"kraft_phenolic"` or
45/// `"birch_plywood"`. [`hpr_design::materials`] lists them all, each with its density's source.
46///
47/// # Errors
48///
49/// [`Error::UnknownMaterial`] if no built-in material has that id.
50pub fn material(id: &str) -> Result<Material, Error> {
51 materials::find(id)
52 .map(|builtin| builtin.material())
53 .ok_or_else(|| Error::UnknownMaterial(id.to_owned()))
54}
55
56/// A nose cone. Its base takes the rocket's diameter, unless it states its own, as a catalog
57/// part does ([`Nose::from_catalog`]).
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Nose {
60 shape: NoseShape,
61 length_m: f64,
62 wall: Wall,
63 /// The shoulder; an outer radius of zero takes the inner radius of the tube behind it.
64 shoulder: Option<Shoulder>,
65 material: Material,
66 name: String,
67 /// The base diameter, m, where the nose states its own: a catalog part's.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 diameter_m: Option<f64>,
70}
71
72impl Nose {
73 /// A hollow nose cone of `shape` and `length_m`, with a wall `wall_m` thick.
74 #[must_use]
75 pub fn hollow(shape: NoseShape, length_m: f64, wall_m: f64, material: Material) -> Self {
76 Self {
77 shape,
78 length_m,
79 wall: Wall::Shell {
80 thickness_m: wall_m,
81 },
82 shoulder: None,
83 material,
84 name: String::new(),
85 diameter_m: None,
86 }
87 }
88
89 /// A solid nose cone of `shape` and `length_m`.
90 #[must_use]
91 pub fn solid(shape: NoseShape, length_m: f64, material: Material) -> Self {
92 Self {
93 wall: Wall::Filled {},
94 ..Self::hollow(shape, length_m, 0.0, material)
95 }
96 }
97
98 /// The same nose with a shoulder, the sleeve behind its base that fits inside the tube
99 /// behind it: `length_m` long, `wall_m` thick, open at its aft end. Its outer radius is that
100 /// tube's inner radius.
101 #[must_use]
102 pub fn with_shoulder(mut self, length_m: f64, wall_m: f64) -> Self {
103 self.shoulder = Some(Shoulder {
104 length_m,
105 outer_radius_m: 0.0,
106 thickness_m: wall_m,
107 capped: false,
108 });
109 self
110 }
111
112 /// The same nose with a shoulder as [`Nose::with_shoulder`] makes it, closed at its aft end
113 /// by a disc as thick as its wall.
114 #[must_use]
115 pub fn with_capped_shoulder(self, length_m: f64, wall_m: f64) -> Self {
116 let mut nose = self.with_shoulder(length_m, wall_m);
117 if let Some(shoulder) = &mut nose.shoulder {
118 shoulder.capped = true;
119 }
120 nose
121 }
122
123 /// The same nose with `name`, which the design file keeps.
124 #[must_use]
125 pub fn named(mut self, name: &str) -> Self {
126 name.clone_into(&mut self.name);
127 self
128 }
129}
130
131/// A body tube: a length of the airframe.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct Tube {
134 length_m: f64,
135 wall_m: f64,
136 diameter_m: Option<f64>,
137 material: Material,
138 name: String,
139}
140
141impl Tube {
142 /// A tube `length_m` long with a wall `wall_m` thick. Its outer diameter is the aft diameter
143 /// of the body part before it, or the rocket's if it is the first.
144 #[must_use]
145 pub fn new(length_m: f64, wall_m: f64, material: Material) -> Self {
146 Self {
147 length_m,
148 wall_m,
149 diameter_m: None,
150 material,
151 name: String::new(),
152 }
153 }
154
155 /// The same tube with outer diameter `diameter_m`. A diameter other than the aft diameter of
156 /// the part before it makes a step in the airframe, which the design's checks warn of; a
157 /// [`Transition`] joins two diameters smoothly.
158 #[must_use]
159 pub fn with_diameter_m(mut self, diameter_m: f64) -> Self {
160 self.diameter_m = Some(diameter_m);
161 self
162 }
163
164 /// The same tube with `name`, which the design file keeps.
165 #[must_use]
166 pub fn named(mut self, name: &str) -> Self {
167 name.clone_into(&mut self.name);
168 self
169 }
170}
171
172/// A transition between two diameters: a boattail narrowing toward the tail, or a cone stepping
173/// the airframe up or down. Its fore end takes the diameter of the body part before it.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct Transition {
176 shape: NoseShape,
177 length_m: f64,
178 aft_diameter_m: f64,
179 wall: Wall,
180 material: Material,
181 name: String,
182 /// The fore diameter, m, where the transition states its own: a catalog part's.
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 fore_diameter_m: Option<f64>,
185 /// Whether its profile is cut from a whole nose cone ([`hpr_design::Transition::clipped`]).
186 #[serde(default)]
187 clipped: bool,
188 /// The shoulder ahead of its fore end, its outer radius stated.
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 fore_shoulder: Option<Shoulder>,
191 /// The shoulder behind its aft end, its outer radius stated.
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 aft_shoulder: Option<Shoulder>,
194}
195
196impl Transition {
197 /// A hollow conical transition `length_m` long, to `aft_diameter_m` at its aft end, with a
198 /// wall `wall_m` thick.
199 #[must_use]
200 pub fn conical(length_m: f64, aft_diameter_m: f64, wall_m: f64, material: Material) -> Self {
201 Self {
202 shape: NoseShape::Conical {},
203 length_m,
204 aft_diameter_m,
205 wall: Wall::Shell {
206 thickness_m: wall_m,
207 },
208 material,
209 name: String::new(),
210 fore_diameter_m: None,
211 clipped: false,
212 fore_shoulder: None,
213 aft_shoulder: None,
214 }
215 }
216
217 /// The same transition with the profile `shape` instead of a cone. It stays clipped, or
218 /// not, as it was ([`Transition::with_clipped`]).
219 #[must_use]
220 pub fn with_shape(mut self, shape: NoseShape) -> Self {
221 self.shape = shape;
222 self
223 }
224
225 /// The same transition with its profile cut from a whole nose cone (`true`), or scaled
226 /// between its two radii (`false`, as [`Transition::conical`] makes it):
227 /// [`hpr_design::Transition::clipped`]. A conical or tangent-ogive transition is the same
228 /// either way.
229 #[must_use]
230 pub fn with_clipped(mut self, clipped: bool) -> Self {
231 self.clipped = clipped;
232 self
233 }
234
235 /// The same transition, solid.
236 #[must_use]
237 pub fn solid(mut self) -> Self {
238 self.wall = Wall::Filled {};
239 self
240 }
241
242 /// The same transition with `name`, which the design file keeps.
243 #[must_use]
244 pub fn named(mut self, name: &str) -> Self {
245 name.clone_into(&mut self.name);
246 self
247 }
248}
249
250/// A set of identical fins spaced evenly around the last body tube, flush with its aft end
251/// unless placed elsewhere with [`Fins::at`]. Their edges are square unless
252/// [`Fins::with_cross_section`] says otherwise.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct Fins {
255 set: FinSet,
256 position: Position,
257 name: String,
258}
259
260impl Fins {
261 /// `count` fins of the shape `planform`, `thickness_m` thick. The planform names its
262 /// dimensions: a trapezoid's root and tip chords, its span, and how far aft of the root's
263 /// leading edge the tip's is ([`FinPlanform`]).
264 ///
265 /// ```
266 /// use hpr::FinPlanform;
267 /// use hpr::rocket::{Fins, material};
268 ///
269 /// let planform = FinPlanform::Trapezoidal {
270 /// root_chord_m: 0.1,
271 /// tip_chord_m: 0.04,
272 /// span_m: 0.045,
273 /// sweep_m: 0.05,
274 /// };
275 /// let fins = Fins::new(3, planform, 0.003175, material("birch_plywood")?);
276 /// # Ok::<(), hpr::Error>(())
277 /// ```
278 #[must_use]
279 pub fn new(count: u32, planform: FinPlanform, thickness_m: f64, material: Material) -> Self {
280 Self {
281 set: FinSet {
282 count,
283 planform,
284 thickness_m,
285 cross_section: FinCrossSection::Square,
286 tab: None,
287 fillet: None,
288 cant_rad: 0.0,
289 base_angle_rad: 0.0,
290 material,
291 },
292 position: Position::Bottom { aft_offset_m: 0.0 },
293 name: String::new(),
294 }
295 }
296
297 /// The same fins with edges shaped `cross_section`: square, rounded or an airfoil. It changes
298 /// their drag.
299 #[must_use]
300 pub fn with_cross_section(mut self, cross_section: FinCrossSection) -> Self {
301 self.set.cross_section = cross_section;
302 self
303 }
304
305 /// The same fins canted by `cant_deg`, which spins the rocket (the sign convention is
306 /// [`FinSet::cant_rad`]'s).
307 #[must_use]
308 pub fn with_cant_deg(mut self, cant_deg: f64) -> Self {
309 self.set.cant_rad = cant_deg.to_radians();
310 self
311 }
312
313 /// The same fins at `position` along their tube: [`Position`] places their root's leading
314 /// edge (`Top`, `After`, `Absolute`), its trailing edge (`Bottom`) or its middle (`Middle`).
315 #[must_use]
316 pub fn at(mut self, position: Position) -> Self {
317 self.position = position;
318 self
319 }
320
321 /// The same fins with `name`, which the design file keeps.
322 #[must_use]
323 pub fn named(mut self, name: &str) -> Self {
324 name.clone_into(&mut self.name);
325 self
326 }
327}
328
329/// The tube inside the airframe that holds the motor, flush with the last body tube's aft end
330/// unless placed elsewhere with [`MotorTube::at`].
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct MotorTube {
333 length_m: f64,
334 inner_diameter_m: f64,
335 wall_m: f64,
336 overhang_m: f64,
337 position: Position,
338 material: Material,
339 name: String,
340}
341
342impl MotorTube {
343 /// A motor tube `length_m` long, with a bore of `inner_diameter_m` and a wall `wall_m` thick.
344 #[must_use]
345 pub fn new(length_m: f64, inner_diameter_m: f64, wall_m: f64, material: Material) -> Self {
346 Self {
347 length_m,
348 inner_diameter_m,
349 wall_m,
350 overhang_m: 0.0,
351 position: Position::Bottom { aft_offset_m: 0.0 },
352 material,
353 name: String::new(),
354 }
355 }
356
357 /// The same tube with the motor's aft end `overhang_m` past the tube's.
358 #[must_use]
359 pub fn with_overhang_m(mut self, overhang_m: f64) -> Self {
360 self.overhang_m = overhang_m;
361 self
362 }
363
364 /// The same tube at `position` along the body tube it is in.
365 #[must_use]
366 pub fn at(mut self, position: Position) -> Self {
367 self.position = position;
368 self
369 }
370
371 /// The same tube with `name`, which the design file keeps.
372 #[must_use]
373 pub fn named(mut self, name: &str) -> Self {
374 name.clone_into(&mut self.name);
375 self
376 }
377}
378
379/// A mass inside the last body tube: a parachute and its cord, an altimeter bay, ballast.
380///
381/// It is a solid cylinder along the axis, as long and as wide as its packing, or a point until
382/// [`Mass::packed`] gives it a size. Its [`Position`] places the packing's fore end (`Top`,
383/// `After`, `Absolute`), its aft end (`Bottom`) or its middle (`Middle`). So packing a mass
384/// moves its center by half the packed length, unless it is placed by its middle.
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct Mass {
387 mass_kg: f64,
388 position: Position,
389 packed_length_m: f64,
390 packed_diameter_m: f64,
391 name: String,
392}
393
394impl Mass {
395 /// `mass_kg` at `position` along the body tube it is in.
396 #[must_use]
397 pub fn new(mass_kg: f64, position: Position) -> Self {
398 Self {
399 mass_kg,
400 position,
401 packed_length_m: 0.0,
402 packed_diameter_m: 0.0,
403 name: String::new(),
404 }
405 }
406
407 /// The same mass packed in a cylinder `length_m` long and `diameter_m` across.
408 #[must_use]
409 pub fn packed(mut self, length_m: f64, diameter_m: f64) -> Self {
410 self.packed_length_m = length_m;
411 self.packed_diameter_m = diameter_m;
412 self
413 }
414
415 /// The same mass with `name`, which the design file keeps.
416 #[must_use]
417 pub fn named(mut self, name: &str) -> Self {
418 name.clone_into(&mut self.name);
419 self
420 }
421}
422
423/// A rocket: its design, its motor and its recovery devices.
424///
425/// It serializes, for a record of what was flown, but doesn't deserialize: the builder's methods
426/// check what goes in, so a rocket is built with them, or read from a design with
427/// [`Rocket::from_design`].
428///
429/// ```
430/// use hpr::rocket::{Fins, Mass, MotorTube, Nose, Tube, material};
431/// use hpr::{FinPlanform, Motor, NoseShape, Position, Rocket};
432///
433/// let mut rocket = Rocket::new("Small", 0.0563)?;
434/// let ogive = NoseShape::Ogive { radius_ratio: 1.0 };
435/// let planform = FinPlanform::Trapezoidal {
436/// root_chord_m: 0.1,
437/// tip_chord_m: 0.04,
438/// span_m: 0.045,
439/// sweep_m: 0.05,
440/// };
441/// rocket
442/// .add_nose(Nose::hollow(ogive, 0.22, 0.0015, material("abs")?))?
443/// .add_tube(Tube::new(0.9, 0.00115, material("kraft_phenolic")?))?
444/// .add_fins(Fins::new(3, planform, 0.003175, material("birch_plywood")?))?
445/// .add_motor_tube(MotorTube::new(0.2, 0.029, 0.001, material("kraft_phenolic")?))?
446/// .set_motor(Motor::from_catalog("H54")?)?;
447///
448/// // Unstable as it stands: the motor's weight puts the center of gravity behind the center of
449/// // pressure, and the margin, in calibres, is negative.
450/// assert!(rocket.static_margin_cal(0.0, 0.3)?.is_some_and(|margin| margin < 0.0));
451/// // 200 g near the top of the tube, a recovery bay, brings it forward of it.
452/// rocket.add_mass(Mass::new(0.2, Position::Top { aft_offset_m: 0.07 }))?;
453/// assert!(rocket.static_margin_cal(0.0, 0.3)?.is_some_and(|margin| margin > 1.0));
454/// # Ok::<(), hpr::Error>(())
455/// ```
456#[derive(Debug, Clone, PartialEq, Serialize)]
457pub struct Rocket {
458 design: hpr_design::Rocket,
459 configuration_id: Option<String>,
460 recovery: Vec<Device>,
461 /// Where the builder has got to; `None` for a rocket read from a design. Not part of the
462 /// record.
463 #[serde(skip)]
464 build: Option<Build>,
465}
466
467/// The builder's place in the tree.
468#[derive(Debug, Clone, PartialEq)]
469struct Build {
470 /// The rocket's body diameter, m.
471 diameter_m: f64,
472 /// The last body part's aft radius, m; `None` before the first.
473 aft_radius_m: Option<f64>,
474 /// The index of the last body tube among the stage's components.
475 tube: Option<usize>,
476 /// The motor tube's component id, once there is one.
477 motor_tube: Option<String>,
478}
479
480/// The id of the stage the builder makes.
481const STAGE_ID: &str = "sustainer";
482
483impl Rocket {
484 /// An empty rocket called `name`, of outer body diameter `diameter_m`: the nose's base
485 /// diameter, and the first tube's, unless they say otherwise (a catalog part states its
486 /// own). Its reference diameter, the one
487 /// the stability margin is counted in, is its largest body diameter.
488 ///
489 /// # Errors
490 ///
491 /// [`Error::Domain`] for a diameter that isn't finite and positive.
492 pub fn new(name: &str, diameter_m: f64) -> Result<Self, Error> {
493 Ok(Self {
494 design: hpr_design::Rocket {
495 name: name.to_owned(),
496 stages: vec![Stage {
497 id: STAGE_ID.to_owned(),
498 name: String::new(),
499 components: Vec::new(),
500 overrides: Overrides::default(),
501 drag_override: None,
502 parallel: None,
503 }],
504 reference_diameter: ReferenceDiameter::Maximum {},
505 configurations: Vec::new(),
506 },
507 configuration_id: None,
508 recovery: Vec::new(),
509 build: Some(Build {
510 diameter_m: positive("rocket diameter, m", diameter_m)?,
511 aft_radius_m: None,
512 tube: None,
513 motor_tube: None,
514 }),
515 })
516 }
517
518 /// A rocket from a design you already have, flown in its configuration `configuration_id`:
519 /// a design file read with `serde_json`, an OpenRocket file read with [`hpr_io::ork`], or a
520 /// built rocket's [`Rocket::design`] changed with [`hpr_design`] to hold what the builder
521 /// can't, such as a cluster or pods. Parts can't be added to it; recovery devices can.
522 ///
523 /// # Errors
524 ///
525 /// [`Error::NoSuchConfiguration`] if the design has no configuration of that id.
526 pub fn from_design(design: hpr_design::Rocket, configuration_id: &str) -> Result<Self, Error> {
527 if design.configuration(configuration_id).is_none() {
528 return Err(Error::NoSuchConfiguration(configuration_id.to_owned()));
529 }
530 Ok(Self {
531 design,
532 configuration_id: Some(configuration_id.to_owned()),
533 recovery: Vec::new(),
534 build: None,
535 })
536 }
537
538 /// Adds the nose cone, which goes first.
539 ///
540 /// # Errors
541 ///
542 /// - [`Error::Order`] if a body part is already in place, or the rocket was read from a
543 /// design.
544 /// - [`Error::Domain`] for a diameter, length, wall or shoulder dimension that isn't finite
545 /// and positive, or a shoulder wall thicker than its stated radius; [`Error::Design`] for a
546 /// shape whose parameter is out of its range.
547 pub fn add_nose(&mut self, nose: Nose) -> Result<&mut Self, Error> {
548 let build = self.build()?;
549 if build.aft_radius_m.is_some() {
550 return Err(Error::Order(Order::NoseNotFirst));
551 }
552 let radius_m = 0.5
553 * positive(
554 "nose diameter, m",
555 nose.diameter_m.unwrap_or(build.diameter_m),
556 )?;
557 positive("nose length, m", nose.length_m)?;
558 check_wall("nose wall, m", nose.wall)?;
559 Profile::nose(nose.shape, nose.length_m, radius_m)?;
560 let mut auto = Vec::new();
561 if let Some(shoulder) = &nose.shoulder {
562 check_shoulder(&NOSE_SHOULDER, shoulder)?;
563 if shoulder.outer_radius_m == 0.0 {
564 auto.push(AutoDimension::ShoulderRadius);
565 }
566 }
567 let part = Part::NoseCone(NoseCone {
568 shape: nose.shape,
569 length_m: nose.length_m,
570 base_radius_m: radius_m,
571 wall: nose.wall,
572 shoulder: nose.shoulder,
573 material: nose.material,
574 });
575 let mut component = self.component("nose", &nose.name, part, None);
576 component.auto = auto;
577 self.push_body(component, radius_m, false)?;
578 Ok(self)
579 }
580
581 /// Adds a body tube behind the last body part.
582 ///
583 /// # Errors
584 ///
585 /// - [`Error::Order`] if the rocket was read from a design.
586 /// - [`Error::Domain`] for a length, wall or diameter that isn't finite and positive.
587 pub fn add_tube(&mut self, tube: Tube) -> Result<&mut Self, Error> {
588 let build = self.build()?;
589 let before_m = build
590 .aft_radius_m
591 .map_or(build.diameter_m, |radius_m| 2.0 * radius_m);
592 let diameter_m = positive("tube diameter, m", tube.diameter_m.unwrap_or(before_m))?;
593 let part = Part::BodyTube(BodyTube {
594 length_m: positive("tube length, m", tube.length_m)?,
595 outer_radius_m: 0.5 * diameter_m,
596 thickness_m: positive("tube wall, m", tube.wall_m)?,
597 material: tube.material,
598 });
599 let component = self.component("tube", &tube.name, part, None);
600 self.push_body(component, 0.5 * diameter_m, true)?;
601 Ok(self)
602 }
603
604 /// Adds a transition behind the last body part, starting at its diameter unless it states its
605 /// own (a catalog part does).
606 ///
607 /// # Errors
608 ///
609 /// - [`Error::Order`] if there is no body part before it, or the rocket was read from a
610 /// design.
611 /// - [`Error::Domain`] for a length, wall, stated fore diameter or shoulder dimension that
612 /// isn't finite and positive, an aft diameter that is negative or not finite, or a
613 /// shoulder wall thicker than its radius; [`Error::Design`] for a shape whose parameter is
614 /// out of its range.
615 pub fn add_transition(&mut self, transition: Transition) -> Result<&mut Self, Error> {
616 let before_radius_m = self
617 .build()?
618 .aft_radius_m
619 .ok_or(Error::Order(Order::NothingBeforeTransition))?;
620 let fore_radius_m = match transition.fore_diameter_m {
621 Some(diameter_m) => 0.5 * positive("transition fore diameter, m", diameter_m)?,
622 None => before_radius_m,
623 };
624 positive("transition length, m", transition.length_m)?;
625 check_wall("transition wall, m", transition.wall)?;
626 let aft_radius_m =
627 0.5 * non_negative("transition aft diameter, m", transition.aft_diameter_m)?;
628 Profile::transition(
629 transition.shape,
630 transition.length_m,
631 fore_radius_m,
632 aft_radius_m,
633 transition.clipped,
634 )?;
635 for shoulder in [&transition.fore_shoulder, &transition.aft_shoulder]
636 .into_iter()
637 .flatten()
638 {
639 // A transition's shoulders state their radius: the builder finds none for them.
640 positive("transition shoulder radius, m", shoulder.outer_radius_m)?;
641 check_shoulder(&TRANSITION_SHOULDER, shoulder)?;
642 }
643 let part = Part::Transition(hpr_design::Transition {
644 shape: transition.shape,
645 clipped: transition.clipped,
646 length_m: transition.length_m,
647 fore_radius_m,
648 aft_radius_m,
649 wall: transition.wall,
650 fore_shoulder: transition.fore_shoulder,
651 aft_shoulder: transition.aft_shoulder,
652 material: transition.material,
653 });
654 let component = self.component("transition", &transition.name, part, None);
655 self.push_body(component, aft_radius_m, false)?;
656 Ok(self)
657 }
658
659 /// Adds a fin set to the last body tube.
660 ///
661 /// # Errors
662 ///
663 /// - [`Error::Order`] if there is no body tube yet, or the rocket was read from a design.
664 /// - [`Error::Design`] for fins the design refuses ([`FinSet::validate`]): no fins, a chord,
665 /// span or thickness that isn't finite and positive, and the like.
666 /// - [`Error::Domain`] for a position that isn't finite.
667 pub fn add_fins(&mut self, fins: Fins) -> Result<&mut Self, Error> {
668 fins.set.validate()?;
669 check_position(fins.position)?;
670 let component = self.component(
671 "fins",
672 &fins.name,
673 Part::FinSet(fins.set),
674 Some(fins.position),
675 );
676 self.attach(component)?;
677 Ok(self)
678 }
679
680 /// Adds the motor tube to the last body tube. A rocket has one.
681 ///
682 /// # Errors
683 ///
684 /// - [`Error::Order`] if there is no body tube yet, the rocket has a motor tube already, or
685 /// it was read from a design.
686 /// - [`Error::Domain`] for a length, bore or wall that isn't finite and positive, an overhang
687 /// that is negative or not finite, or a position that isn't finite.
688 pub fn add_motor_tube(&mut self, tube: MotorTube) -> Result<&mut Self, Error> {
689 if self.build()?.motor_tube.is_some() {
690 return Err(Error::Order(Order::SecondMotorTube));
691 }
692 let wall_m = positive("motor tube wall, m", tube.wall_m)?;
693 let part = Part::InnerTube(InnerTube {
694 length_m: positive("motor tube length, m", tube.length_m)?,
695 outer_radius_m: 0.5 * positive("motor tube bore, m", tube.inner_diameter_m)? + wall_m,
696 thickness_m: wall_m,
697 radial_offset_m: 0.0,
698 angle_rad: 0.0,
699 material: tube.material,
700 cluster_m: Vec::new(),
701 });
702 check_position(tube.position)?;
703 let mut component = self.component("motor-tube", &tube.name, part, Some(tube.position));
704 component.motor_mount = Some(MotorMount {
705 overhang_m: non_negative("motor overhang, m", tube.overhang_m)?,
706 });
707 let id = component.id.clone();
708 self.attach(component)?;
709 if let Some(build) = &mut self.build {
710 build.motor_tube = Some(id);
711 }
712 Ok(self)
713 }
714
715 /// Adds a mass inside the last body tube.
716 ///
717 /// # Errors
718 ///
719 /// - [`Error::Order`] if there is no body tube yet, or the rocket was read from a design.
720 /// - [`Error::Domain`] for a mass or packing that is negative or not finite, or a position
721 /// that isn't finite.
722 pub fn add_mass(&mut self, mass: Mass) -> Result<&mut Self, Error> {
723 non_negative("mass, kg", mass.mass_kg)?;
724 non_negative("packed length, m", mass.packed_length_m)?;
725 non_negative("packed diameter, m", mass.packed_diameter_m)?;
726 check_position(mass.position)?;
727 let part = Part::MassComponent(MassComponent {
728 mass_kg: mass.mass_kg,
729 packing: Packing {
730 length_m: mass.packed_length_m,
731 radius_m: 0.5 * mass.packed_diameter_m,
732 radial_offset_m: 0.0,
733 angle_rad: 0.0,
734 },
735 });
736 let component = self.component("mass", &mass.name, part, Some(mass.position));
737 self.attach(component)?;
738 Ok(self)
739 }
740
741 /// Adds a fitting to the last body tube: a coupler, a centering ring, a bulkhead, a launch
742 /// lug, or a packed parachute or streamer.
743 ///
744 /// # Errors
745 ///
746 /// - [`Error::Order`] if there is no body tube yet, the rocket was read from a design, or the
747 /// fitting holds a part of another kind (one read from a file).
748 /// - [`Error::Design`] for a part the design can't weigh: a size that is negative or not
749 /// finite, a ring whose bore reaches its rim, a wall thicker than its tube's radius, a
750 /// material of the wrong kind. A tube with a wall of zero, or a parachute or streamer of
751 /// no size, weighs nothing and is taken.
752 /// - [`Error::Domain`] for a position that isn't finite.
753 pub fn add_fitting(&mut self, fitting: Fitting) -> Result<&mut Self, Error> {
754 let id = fitting.id().ok_or(Error::Order(Order::NotAFitting))?;
755 let (part, position, name) = fitting.into_parts();
756 check_position(position)?;
757 // Weighed now, on the tube it goes on, so a part the design can't take is refused where
758 // it is added.
759 part.mass_properties(Some(self.tube_radius_m()?))?;
760 let component = self.component(id, &name, part, Some(position));
761 self.attach(component)?;
762 Ok(self)
763 }
764
765 /// Puts `motor` in the motor tube, lit at launch, in place of any motor there before. The
766 /// design's one configuration is named after its designation.
767 ///
768 /// # Errors
769 ///
770 /// [`Error::Order`] if there is no motor tube yet, or the rocket was read from a design.
771 pub fn set_motor(&mut self, motor: Motor) -> Result<&mut Self, Error> {
772 let mount = self
773 .build()?
774 .motor_tube
775 .clone()
776 .ok_or(Error::Order(Order::NoMotorTube))?;
777 let id = motor.designation().to_owned();
778 self.design.configurations = vec![Configuration {
779 id: id.clone(),
780 name: String::new(),
781 motors: vec![MountedMotor {
782 mount,
783 designation: id.clone(),
784 diameter_m: motor.diameter_m(),
785 length_m: motor.length_m(),
786 motor: motor.solid_motor().clone(),
787 delay: motor.delay(),
788 ignition: Ignition::Launch,
789 failed_tubes: Vec::new(),
790 }],
791 }];
792 self.configuration_id = Some(id);
793 Ok(self)
794 }
795
796 /// Adds a recovery device: a parachute, a streamer or a tumble, with what opens it.
797 /// [`Trigger::MotorDelay`](hpr_sim::Trigger::MotorDelay) with motor 0, the first motor, opens
798 /// it at the motor's ejection charge, which needs the motor's delay set
799 /// ([`Motor::with_delay_s`]). The device adds drag, not mass: add its mass with
800 /// [`Rocket::add_mass`], or a catalog parachute's with [`Fitting::from_catalog`].
801 pub fn add_parachute(&mut self, device: Device) -> &mut Self {
802 self.recovery.push(device);
803 self
804 }
805
806 /// The design: the same tree a design file holds.
807 #[must_use]
808 pub fn design(&self) -> &hpr_design::Rocket {
809 &self.design
810 }
811
812 /// The configuration the rocket flies in: the motor's designation for a built rocket, `None`
813 /// before it has a motor.
814 #[must_use]
815 pub fn configuration_id(&self) -> Option<&str> {
816 self.configuration_id.as_deref()
817 }
818
819 /// The recovery devices, in the order they were added.
820 #[must_use]
821 pub fn recovery(&self) -> &[Device] {
822 &self.recovery
823 }
824
825 /// The parts placed and the motor in its tube ([`hpr_design::Rocket::assemble`]), once the
826 /// design's checks ([`hpr_design::checks`]) find no errors: the same checks a flight runs
827 /// with the default settings, so what this weighs is what [`crate::Flight`] would fly. A
828 /// flight told to accept a design's errors
829 /// ([`FlightSettings::accept_design_errors`](hpr_sim::FlightSettings::accept_design_errors))
830 /// flies what this refuses; `design().assemble(id)` weighs it.
831 ///
832 /// # Errors
833 ///
834 /// - [`Error::NoMotor`] before the rocket has a motor.
835 /// - [`Error::DesignChecks`] with every finding if the checks find an error, such as a motor
836 /// wider than its tube's bore or a motor tube wider than the airframe.
837 /// - [`Error::Design`] for a tree that doesn't hold together.
838 pub fn assemble(&self) -> Result<Assembly, Error> {
839 let id = self.configuration_id.as_deref().ok_or(Error::NoMotor)?;
840 let findings = check(&self.design)?;
841 if has_errors(&findings) {
842 return Err(Error::DesignChecks(findings));
843 }
844 Ok(self.design.assemble(id)?)
845 }
846
847 /// The mass, center of gravity and inertia `time_s` seconds after the motor lights, the
848 /// motor burning as its thrust curve says. The center of gravity is in the body frame: `z`
849 /// points to the nose and is zero at its tip, so a point `s` meters aft of the tip is at
850 /// `z = −s` (`docs/physics/frames.md`).
851 ///
852 /// # Errors
853 ///
854 /// As [`Rocket::assemble`], and [`Error::Domain`] for a time that is negative or not finite.
855 pub fn mass_properties(&self, time_s: f64) -> Result<MassProperties, Error> {
856 let time_s = non_negative("time, s", time_s)?;
857 Ok(self.assemble()?.mass_properties(time_s))
858 }
859
860 /// The static stability margin `time_s` seconds after the motor lights, at Mach `mach` with
861 /// the air along the axis, in the rocket's weakest plane ([`Rocket::margin`]), calibres of the
862 /// reference diameter: how far the center of pressure is behind the center of gravity. `None`
863 /// where [`hpr_sim::metrics::margin`] leaves it
864 /// undefined: a normal force that doesn't restore, or a center of pressure too ill-conditioned
865 /// to place. [`Rocket::margin`] gives the rest of it.
866 ///
867 /// # Errors
868 ///
869 /// As [`Rocket::margin`].
870 pub fn static_margin_cal(&self, time_s: f64, mach: f64) -> Result<Option<f64>, Error> {
871 Ok(self.margin(time_s, mach)?.margin_cal)
872 }
873
874 /// The center of pressure and the static margin `time_s` seconds after the motor lights, at
875 /// Mach `mach` with the air along the axis, in the weakest plane the air can cross the rocket
876 /// in ([`hpr_sim::metrics::weakest_margin`]): a rocket with a fin set of one or two fins has a
877 /// margin in each plane, and the least is the one to trust. Its center of pressure is a
878 /// station: meters aft of the nose tip, positive.
879 ///
880 /// # Errors
881 ///
882 /// As [`Rocket::mass_properties`], [`Error::Aero`] for a rocket the aerodynamic model can't
883 /// take, and [`Error::Sim`] for a Mach number out of its range.
884 pub fn margin(&self, time_s: f64, mach: f64) -> Result<Margin, Error> {
885 let time_s = non_negative("time, s", time_s)?;
886 let assembly = self.assemble()?;
887 let cg_station_m = -assembly.mass_properties(time_s).cg_m.z;
888 let aero = AeroModel::new(&assembly.layout)?;
889 Ok(metrics::weakest_margin(&aero, mach, cg_station_m)?)
890 }
891
892 /// The builder's place, or [`Error::Order`] for a rocket read from a design.
893 fn build(&self) -> Result<&Build, Error> {
894 self.build
895 .as_ref()
896 .ok_or(Error::Order(Order::ReadFromDesign))
897 }
898
899 /// The builder's stage: its one, the first.
900 fn stage(&mut self) -> Result<&mut Stage, Error> {
901 self.design
902 .stages
903 .first_mut()
904 .ok_or(Error::Order(Order::ReadFromDesign))
905 }
906
907 /// A component of the stage, its id `kind` or, if that is taken, `kind-2`, `kind-3` and on.
908 fn component(
909 &self,
910 kind: &str,
911 name: &str,
912 part: Part,
913 position: Option<Position>,
914 ) -> Component {
915 let mut ids = Vec::new();
916 for stage in &self.design.stages {
917 for component in &stage.components {
918 ids.push(component.id.as_str());
919 ids.extend(component.children.iter().map(|child| child.id.as_str()));
920 }
921 }
922 let mut id = kind.to_owned();
923 let mut n = 1;
924 while ids.contains(&id.as_str()) {
925 n += 1;
926 id = format!("{kind}-{n}");
927 }
928 Component {
929 id,
930 name: name.to_owned(),
931 part,
932 position,
933 auto: Vec::new(),
934 motor_mount: None,
935 finish: None,
936 overrides: Overrides::default(),
937 overrides_include_children: false,
938 drag_override: None,
939 children: Vec::new(),
940 }
941 }
942
943 /// Adds a body component with aft radius `aft_radius_m`; a `tube` takes the attached parts
944 /// that follow.
945 fn push_body(
946 &mut self,
947 component: Component,
948 aft_radius_m: f64,
949 tube: bool,
950 ) -> Result<(), Error> {
951 let components = &mut self.stage()?.components;
952 components.push(component);
953 let index = components.len() - 1;
954 if let Some(build) = &mut self.build {
955 build.aft_radius_m = Some(aft_radius_m);
956 if tube {
957 build.tube = Some(index);
958 }
959 }
960 Ok(())
961 }
962
963 /// The last body tube's outer radius, m.
964 fn tube_radius_m(&self) -> Result<f64, Error> {
965 let index = self.build()?.tube.ok_or(Error::Order(Order::NoTube))?;
966 match self
967 .design
968 .stages
969 .first()
970 .and_then(|stage| stage.components.get(index))
971 .map(|component| &component.part)
972 {
973 Some(Part::BodyTube(tube)) => Ok(tube.outer_radius_m),
974 _ => Err(Error::Order(Order::NoTube)),
975 }
976 }
977
978 /// Attaches `component` to the last body tube.
979 fn attach(&mut self, component: Component) -> Result<(), Error> {
980 let index = self.build()?.tube.ok_or(Error::Order(Order::NoTube))?;
981 self.stage()?
982 .components
983 .get_mut(index)
984 .ok_or(Error::Order(Order::NoTube))?
985 .children
986 .push(component);
987 Ok(())
988 }
989}
990
991/// A wall's thickness, if it has one, checked finite and positive.
992fn check_wall(what: &'static str, wall: Wall) -> Result<(), Error> {
993 if let Wall::Shell { thickness_m } = wall {
994 positive(what, thickness_m)?;
995 }
996 Ok(())
997}
998
999/// A shoulder's length and wall, checked finite and positive, and, where its outer radius is
1000/// stated (zero is the tube's, found later), that radius too, with the wall no thicker than it.
1001/// `names` names those four numbers in an error: [`NOSE_SHOULDER`], [`TRANSITION_SHOULDER`].
1002fn check_shoulder(names: &[&'static str; 4], shoulder: &Shoulder) -> Result<(), Error> {
1003 let [length, wall, radius, past] = *names;
1004 positive(length, shoulder.length_m)?;
1005 positive(wall, shoulder.thickness_m)?;
1006 if shoulder.outer_radius_m != 0.0 {
1007 positive(radius, shoulder.outer_radius_m)?;
1008 if shoulder.thickness_m > shoulder.outer_radius_m {
1009 return Err(Error::Domain {
1010 what: past,
1011 value: shoulder.thickness_m,
1012 });
1013 }
1014 }
1015 Ok(())
1016}
1017
1018/// A nose shoulder's numbers, as [`check_shoulder`] names them.
1019const NOSE_SHOULDER: [&str; 4] = [
1020 "nose shoulder length, m",
1021 "nose shoulder wall, m",
1022 "nose shoulder radius, m",
1023 "nose shoulder wall past its radius, m",
1024];
1025
1026/// A transition shoulder's numbers, as [`check_shoulder`] names them.
1027const TRANSITION_SHOULDER: [&str; 4] = [
1028 "transition shoulder length, m",
1029 "transition shoulder wall, m",
1030 "transition shoulder radius, m",
1031 "transition shoulder wall past its radius, m",
1032];
1033
1034/// A position's offset or station, checked finite.
1035fn check_position(position: Position) -> Result<(), Error> {
1036 let (Position::Top {
1037 aft_offset_m: value,
1038 }
1039 | Position::Middle {
1040 aft_offset_m: value,
1041 }
1042 | Position::Bottom {
1043 aft_offset_m: value,
1044 }
1045 | Position::After {
1046 aft_offset_m: value,
1047 }
1048 | Position::Absolute { station_m: value }) = position;
1049 finite("position, m", value)?;
1050 Ok(())
1051}