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)]
151pub struct Motor {
152 curve: ThrustCurve,
153 propellant_mass_kg: f64,
154 ignition_time_s: f64,
155 event_times_s: Vec<f64>,
156}
157
158impl Motor {
159 pub fn new(curve: ThrustCurve, propellant_mass_kg: f64, ignition_time_s: f64) -> Result<Self> {
161 positive(propellant_mass_kg, "propellant_mass_kg")?;
162 nonnegative(ignition_time_s, "ignition_time_s")?;
163 positive(
164 curve.total_impulse_ns() / propellant_mass_kg,
165 "effective exhaust velocity",
166 )?;
167 let event_times_s: Vec<_> = curve
168 .points
169 .iter()
170 .map(|p| ignition_time_s + p.time_s)
171 .collect();
172 if event_times_s.iter().any(|t| !t.is_finite())
173 || event_times_s.windows(2).any(|p| p[1] <= p[0])
174 {
175 return Err(Error::invalid(
176 "motor schedule",
177 "event times must be finite and distinguishable",
178 ));
179 }
180 Ok(Self {
181 curve,
182 propellant_mass_kg,
183 ignition_time_s,
184 event_times_s,
185 })
186 }
187
188 pub fn curve(&self) -> &ThrustCurve {
190 &self.curve
191 }
192 pub fn propellant_mass_kg(&self) -> f64 {
194 self.propellant_mass_kg
195 }
196 pub fn ignition_time_s(&self) -> f64 {
198 self.ignition_time_s
199 }
200 pub fn burnout_time_s(&self) -> f64 {
202 *self.event_times_s.last().unwrap()
203 }
204
205 pub fn sample(&self, time_s: f64) -> Result<MotorSample> {
207 nonnegative(time_s, "time_s")?;
208 Ok(self.sample_unchecked(time_s))
209 }
210
211 pub(crate) fn sample_unchecked(&self, time_s: f64) -> MotorSample {
212 if time_s < self.ignition_time_s {
213 return MotorSample {
214 phase: MotorPhase::AwaitingIgnition,
215 thrust_n: 0.0,
216 propellant_mass_kg: self.propellant_mass_kg,
217 };
218 }
219 if time_s >= self.burnout_time_s() {
220 return MotorSample {
221 phase: MotorPhase::BurnedOut,
222 thrust_n: 0.0,
223 propellant_mass_kg: 0.0,
224 };
225 }
226 let elapsed = time_s - self.ignition_time_s;
227 let fraction =
228 (self.curve.impulse(elapsed) / self.curve.total_impulse_ns()).clamp(0.0, 1.0);
229 MotorSample {
230 phase: MotorPhase::Burning,
231 thrust_n: self.curve.thrust(elapsed),
232 propellant_mass_kg: self.propellant_mass_kg * (1.0 - fraction),
233 }
234 }
235
236 pub(crate) fn next_event_after(&self, time_s: f64) -> Option<f64> {
237 self.event_times_s
238 .get(self.event_times_s.partition_point(|t| *t <= time_s))
239 .copied()
240 }
241
242 pub(crate) fn interval_thrust(&self, time_s: f64, burning: bool) -> f64 {
245 if !burning {
246 return 0.0;
247 }
248 self.curve
249 .thrust((time_s - self.ignition_time_s).clamp(0.0, self.curve.duration_s()))
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct Rocket {
256 dry_mass_kg: f64,
257 motor: Option<Motor>,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq)]
262pub struct RocketSample {
263 pub mass_kg: f64,
265 pub motor: MotorSample,
267}
268
269impl Rocket {
270 pub fn new(dry_mass_kg: f64, motor: Option<Motor>) -> Result<Self> {
272 positive(dry_mass_kg, "dry_mass_kg")?;
273 positive(
274 dry_mass_kg + motor.as_ref().map_or(0.0, |m| m.propellant_mass_kg),
275 "wet mass",
276 )?;
277 Ok(Self { dry_mass_kg, motor })
278 }
279 pub fn dry_mass_kg(&self) -> f64 {
281 self.dry_mass_kg
282 }
283 pub fn motor(&self) -> Option<&Motor> {
285 self.motor.as_ref()
286 }
287 pub fn sample(&self, time_s: f64) -> Result<RocketSample> {
289 nonnegative(time_s, "time_s")?;
290 Ok(self.sample_unchecked(time_s))
291 }
292 pub(crate) fn sample_unchecked(&self, time_s: f64) -> RocketSample {
293 let motor = self.motor.as_ref().map_or(
294 MotorSample {
295 phase: MotorPhase::Unpowered,
296 thrust_n: 0.0,
297 propellant_mass_kg: 0.0,
298 },
299 |m| m.sample_unchecked(time_s),
300 );
301 RocketSample {
302 mass_kg: self.dry_mass_kg + motor.propellant_mass_kg,
303 motor,
304 }
305 }
306}