1use crate::{nonnegative, positive, Error, Result};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct ThrustPoint {
6 pub time_s: f64,
8 pub thrust_n: f64,
10}
11
12#[derive(Debug, Clone)]
14pub struct ThrustCurve {
15 points: Vec<ThrustPoint>,
16 cumulative_impulse_ns: Vec<f64>,
17}
18
19impl ThrustCurve {
20 pub fn new(points: Vec<ThrustPoint>) -> Result<Self> {
24 if points.len() < 2 || points[0].time_s != 0.0 {
25 return Err(Error::invalid(
26 "thrust curve",
27 "needs at least two points starting at time zero",
28 ));
29 }
30 let mut impulse = vec![0.0];
31 for (i, point) in points.iter().enumerate() {
32 nonnegative(point.time_s, "thrust point time_s")?;
33 nonnegative(point.thrust_n, "thrust point thrust_n")?;
34 if i > 0 {
35 let previous = points[i - 1];
36 let dt = point.time_s - previous.time_s;
37 positive(dt, "thrust curve time spacing")?;
38 let area = (previous.thrust_n * 0.5 + point.thrust_n * 0.5) * dt;
39 let total = impulse[i - 1] + area;
40 if !total.is_finite() {
41 return Err(Error::invalid(
42 "thrust curve",
43 "total impulse must be finite",
44 ));
45 }
46 impulse.push(total);
47 }
48 }
49 positive(*impulse.last().unwrap(), "total impulse")?;
50 Ok(Self {
51 points,
52 cumulative_impulse_ns: impulse,
53 })
54 }
55
56 pub fn points(&self) -> &[ThrustPoint] {
58 &self.points
59 }
60
61 pub fn duration_s(&self) -> f64 {
63 self.points.last().unwrap().time_s
64 }
65
66 pub fn total_impulse_ns(&self) -> f64 {
68 *self.cumulative_impulse_ns.last().unwrap()
69 }
70
71 pub fn thrust_at(&self, elapsed_s: f64) -> Result<f64> {
75 if !elapsed_s.is_finite() {
76 return Err(Error::invalid("elapsed_s", "must be finite"));
77 }
78 Ok(self.thrust(elapsed_s))
79 }
80
81 pub fn impulse_at(&self, elapsed_s: f64) -> Result<f64> {
83 if !elapsed_s.is_finite() {
84 return Err(Error::invalid("elapsed_s", "must be finite"));
85 }
86 Ok(self.impulse(elapsed_s))
87 }
88
89 fn segment(&self, elapsed_s: f64) -> usize {
90 self.points
91 .partition_point(|p| p.time_s <= elapsed_s)
92 .saturating_sub(1)
93 .min(self.points.len() - 2)
94 }
95
96 pub(crate) fn thrust(&self, elapsed_s: f64) -> f64 {
97 if elapsed_s < 0.0 || elapsed_s > self.duration_s() {
98 return 0.0;
99 }
100 let i = self.segment(elapsed_s);
101 let a = self.points[i];
102 let b = self.points[i + 1];
103 let fraction = (elapsed_s - a.time_s) / (b.time_s - a.time_s);
104 a.thrust_n * (1.0 - fraction) + b.thrust_n * fraction
105 }
106
107 fn impulse(&self, elapsed_s: f64) -> f64 {
108 if elapsed_s <= 0.0 {
109 return 0.0;
110 }
111 if elapsed_s >= self.duration_s() {
112 return self.total_impulse_ns();
113 }
114 let i = self.segment(elapsed_s);
115 let area = (self.points[i].thrust_n * 0.5 + self.thrust(elapsed_s) * 0.5)
116 * (elapsed_s - self.points[i].time_s);
117 (self.cumulative_impulse_ns[i] + area).min(self.total_impulse_ns())
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub enum MotorPhase {
125 Unpowered,
127 AwaitingIgnition,
129 Burning,
131 BurnedOut,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct MotorSample {
138 pub phase: MotorPhase,
140 pub thrust_n: f64,
142 pub propellant_mass_kg: f64,
144}
145
146#[derive(Debug, Clone)]
152pub struct Motor {
153 curve: ThrustCurve,
154 propellant_mass_kg: f64,
155 ignition_time_s: f64,
156 event_times_s: Vec<f64>,
157}
158
159impl Motor {
160 pub fn new(curve: ThrustCurve, propellant_mass_kg: f64, ignition_time_s: f64) -> Result<Self> {
162 positive(propellant_mass_kg, "propellant_mass_kg")?;
163 nonnegative(ignition_time_s, "ignition_time_s")?;
164 positive(
165 curve.total_impulse_ns() / propellant_mass_kg,
166 "effective exhaust velocity",
167 )?;
168 let event_times_s: Vec<_> = curve
169 .points
170 .iter()
171 .map(|p| ignition_time_s + p.time_s)
172 .collect();
173 if event_times_s.iter().any(|t| !t.is_finite())
174 || event_times_s.windows(2).any(|p| p[1] <= p[0])
175 {
176 return Err(Error::invalid(
177 "motor schedule",
178 "event times must be finite and distinguishable",
179 ));
180 }
181 Ok(Self {
182 curve,
183 propellant_mass_kg,
184 ignition_time_s,
185 event_times_s,
186 })
187 }
188
189 pub fn curve(&self) -> &ThrustCurve {
191 &self.curve
192 }
193 pub fn propellant_mass_kg(&self) -> f64 {
195 self.propellant_mass_kg
196 }
197 pub fn ignition_time_s(&self) -> f64 {
199 self.ignition_time_s
200 }
201 pub fn burnout_time_s(&self) -> f64 {
203 *self.event_times_s.last().unwrap()
204 }
205
206 pub fn sample(&self, time_s: f64) -> Result<MotorSample> {
208 nonnegative(time_s, "time_s")?;
209 Ok(self.sample_unchecked(time_s))
210 }
211
212 pub(crate) fn sample_unchecked(&self, time_s: f64) -> MotorSample {
213 if time_s < self.ignition_time_s {
214 return MotorSample {
215 phase: MotorPhase::AwaitingIgnition,
216 thrust_n: 0.0,
217 propellant_mass_kg: self.propellant_mass_kg,
218 };
219 }
220 if time_s >= self.burnout_time_s() {
221 return MotorSample {
222 phase: MotorPhase::BurnedOut,
223 thrust_n: 0.0,
224 propellant_mass_kg: 0.0,
225 };
226 }
227 let elapsed = time_s - self.ignition_time_s;
228 let fraction =
229 (self.curve.impulse(elapsed) / self.curve.total_impulse_ns()).clamp(0.0, 1.0);
230 MotorSample {
231 phase: MotorPhase::Burning,
232 thrust_n: self.curve.thrust(elapsed),
233 propellant_mass_kg: self.propellant_mass_kg * (1.0 - fraction),
234 }
235 }
236
237 pub(crate) fn interval_thrust(&self, time_s: f64, burning: bool) -> f64 {
240 if !burning {
241 return 0.0;
242 }
243 self.curve
244 .thrust((time_s - self.ignition_time_s).clamp(0.0, self.curve.duration_s()))
245 }
246}
247
248#[derive(Debug, Clone)]
256pub struct Stage {
257 dry_mass_kg: f64,
258 motor: Option<Motor>,
259 separation_time_s: Option<f64>,
260}
261
262impl Stage {
263 pub fn new(
268 dry_mass_kg: f64,
269 motor: Option<Motor>,
270 separation_time_s: Option<f64>,
271 ) -> Result<Self> {
272 nonnegative(dry_mass_kg, "stage dry_mass_kg")?;
273 if dry_mass_kg == 0.0 && motor.is_none() {
274 return Err(Error::invalid(
275 "stage",
276 "needs a positive dry mass or a motor",
277 ));
278 }
279 if let Some(t) = separation_time_s {
280 nonnegative(t, "stage separation_time_s")?;
281 }
282 Ok(Self {
283 dry_mass_kg,
284 motor,
285 separation_time_s,
286 })
287 }
288
289 pub fn dry_mass_kg(&self) -> f64 {
291 self.dry_mass_kg
292 }
293 pub fn motor(&self) -> Option<&Motor> {
295 self.motor.as_ref()
296 }
297 pub fn separation_time_s(&self) -> Option<f64> {
299 self.separation_time_s
300 }
301
302 fn attached(&self, time_s: f64) -> bool {
303 self.separation_time_s.is_none_or(|s| time_s < s)
304 }
305}
306
307#[derive(Debug, Clone)]
315pub struct Rocket {
316 base_mass_kg: f64,
317 stages: Vec<Stage>,
318 event_times_s: Vec<f64>,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct RocketSample {
329 pub mass_kg: f64,
331 pub motor: MotorSample,
333}
334
335impl Rocket {
336 pub fn new(dry_mass_kg: f64, motor: Option<Motor>) -> Result<Self> {
339 let stages = motor
340 .map(|m| Stage {
341 dry_mass_kg: 0.0,
342 motor: Some(m),
343 separation_time_s: None,
344 })
345 .into_iter()
346 .collect();
347 Self::assemble(dry_mass_kg, stages)
348 }
349
350 pub fn staged(base_mass_kg: f64, stages: Vec<Stage>) -> Result<Self> {
353 Self::assemble(base_mass_kg, stages)
354 }
355
356 fn assemble(base_mass_kg: f64, stages: Vec<Stage>) -> Result<Self> {
357 positive(base_mass_kg, "base_mass_kg")?;
358 let mut event_times_s = Vec::new();
359 for stage in &stages {
360 if let Some(motor) = &stage.motor {
361 event_times_s.extend_from_slice(&motor.event_times_s);
362 }
363 if let Some(t) = stage.separation_time_s {
364 event_times_s.push(t);
365 }
366 }
367 event_times_s.sort_by(f64::total_cmp);
368 Ok(Self {
369 base_mass_kg,
370 stages,
371 event_times_s,
372 })
373 }
374
375 pub fn dry_mass_kg(&self) -> f64 {
379 self.base_mass_kg
380 }
381 pub fn motor(&self) -> Option<&Motor> {
384 match self.stages.as_slice() {
385 [Stage {
386 motor: Some(m),
387 separation_time_s: None,
388 ..
389 }] => Some(m),
390 _ => None,
391 }
392 }
393 pub fn stages(&self) -> &[Stage] {
395 &self.stages
396 }
397 pub fn sample(&self, time_s: f64) -> Result<RocketSample> {
399 nonnegative(time_s, "time_s")?;
400 Ok(self.sample_unchecked(time_s))
401 }
402 pub(crate) fn sample_unchecked(&self, time_s: f64) -> RocketSample {
403 let mut mass_kg = self.base_mass_kg;
404 let mut thrust_n = 0.0;
405 let mut propellant_mass_kg = 0.0;
406 let mut any_motor = false;
407 let mut any_burning = false;
408 let mut any_awaiting = false;
409 for stage in &self.stages {
410 if !stage.attached(time_s) {
411 continue;
412 }
413 mass_kg += stage.dry_mass_kg;
414 if let Some(motor) = &stage.motor {
415 any_motor = true;
416 let sample = motor.sample_unchecked(time_s);
417 thrust_n += sample.thrust_n;
418 propellant_mass_kg += sample.propellant_mass_kg;
419 match sample.phase {
420 MotorPhase::Burning => any_burning = true,
421 MotorPhase::AwaitingIgnition => any_awaiting = true,
422 _ => {}
423 }
424 }
425 }
426 let phase = if !any_motor {
427 MotorPhase::Unpowered
428 } else if any_burning {
429 MotorPhase::Burning
430 } else if any_awaiting {
431 MotorPhase::AwaitingIgnition
432 } else {
433 MotorPhase::BurnedOut
434 };
435 RocketSample {
436 mass_kg: mass_kg + propellant_mass_kg,
437 motor: MotorSample {
438 phase,
439 thrust_n,
440 propellant_mass_kg,
441 },
442 }
443 }
444
445 pub(crate) fn next_event_after(&self, time_s: f64) -> Option<f64> {
446 self.event_times_s
447 .get(self.event_times_s.partition_point(|t| *t <= time_s))
448 .copied()
449 }
450
451 pub(crate) fn interval_stages(&self, time_s: f64) -> Vec<IntervalStage> {
458 self.stages
459 .iter()
460 .map(|stage| {
461 let attached = stage.attached(time_s);
462 let burning = attached
463 && stage
464 .motor
465 .as_ref()
466 .is_some_and(|m| m.sample_unchecked(time_s).phase == MotorPhase::Burning);
467 IntervalStage { attached, burning }
468 })
469 .collect()
470 }
471
472 pub(crate) fn interval_mass(&self, time_s: f64, stages: &[IntervalStage]) -> f64 {
476 self.base_mass_kg
477 + self
478 .stages
479 .iter()
480 .zip(stages)
481 .map(|(stage, &flags)| {
482 if !flags.attached {
483 0.0
484 } else {
485 stage.dry_mass_kg
486 + stage
487 .motor
488 .as_ref()
489 .map_or(0.0, |m| m.sample_unchecked(time_s).propellant_mass_kg)
490 }
491 })
492 .sum::<f64>()
493 }
494
495 pub(crate) fn interval_thrust(&self, time_s: f64, stages: &[IntervalStage]) -> f64 {
497 self.stages
498 .iter()
499 .zip(stages)
500 .map(|(stage, &flags)| {
501 if flags.burning {
502 stage
503 .motor
504 .as_ref()
505 .map_or(0.0, |m| m.interval_thrust(time_s, true))
506 } else {
507 0.0
508 }
509 })
510 .sum()
511 }
512}
513
514#[derive(Debug, Clone, Copy)]
517pub(crate) struct IntervalStage {
518 pub(crate) attached: bool,
519 pub(crate) burning: bool,
520}