1use std::f64::consts::{FRAC_PI_2, PI};
23
24use hpr_design::{Profile, Wall, revolve};
25use serde::{Deserialize, Serialize};
26
27use crate::error::{AeroError, check_dimension};
28
29pub const BODY_LIFT_K: f64 = 1.1;
33
34#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37#[non_exhaustive]
38pub struct BodyGeometry {
39 pub length_m: f64,
41 pub fore_area_m2: f64,
43 pub aft_area_m2: f64,
45 pub volume_m3: f64,
47 pub planform_area_m2: f64,
49 pub planform_centroid_m: f64,
51 pub aft_angle_rad: f64,
55}
56
57impl BodyGeometry {
58 pub fn cylinder(length_m: f64, radius_m: f64) -> Result<Self, AeroError> {
64 check_dimension("body length", length_m, false)?;
65 check_dimension("body radius", radius_m, false)?;
66 let area = PI * radius_m * radius_m;
67 Ok(Self {
68 length_m,
69 fore_area_m2: area,
70 aft_area_m2: area,
71 volume_m3: area * length_m,
72 planform_area_m2: 2.0 * radius_m * length_m,
73 planform_centroid_m: 0.5 * length_m,
74 aft_angle_rad: 0.0,
75 })
76 }
77
78 pub fn from_profile(profile: &Profile) -> Result<Self, AeroError> {
86 let g = revolve(profile, Wall::Filled {})?;
87 let area = |r: f64| PI * r * r;
88 let geometry = Self {
89 length_m: profile.length_m(),
90 fore_area_m2: area(profile.fore_radius_m()),
91 aft_area_m2: area(profile.aft_radius_m()),
92 volume_m3: g.volume_m3,
93 planform_area_m2: g.planform_area_m2,
94 planform_centroid_m: g.planform_centroid_m,
95 aft_angle_rad: profile.radius_and_slope(profile.length_m()).1.atan(),
96 };
97 geometry.validate()?;
98 Ok(geometry)
99 }
100
101 pub fn validate(&self) -> Result<(), AeroError> {
108 check_dimension("body length", self.length_m, false)?;
109 check_dimension("body fore area", self.fore_area_m2, true)?;
110 check_dimension("body aft area", self.aft_area_m2, true)?;
111 check_dimension("body volume", self.volume_m3, false)?;
112 check_dimension("body planform area", self.planform_area_m2, false)?;
113 if !(-FRAC_PI_2..=FRAC_PI_2).contains(&self.aft_angle_rad) {
114 return Err(AeroError::Domain {
115 what: "body angle at the aft end",
116 value: self.aft_angle_rad,
117 });
118 }
119 if !self.planform_centroid_m.is_finite() {
120 return Err(AeroError::Domain {
121 what: "body planform centroid",
122 value: self.planform_centroid_m,
123 });
124 }
125 Ok(())
126 }
127
128 pub fn normal_force_slope(&self, reference_area_m2: f64) -> f64 {
131 2.0 * (self.aft_area_m2 - self.fore_area_m2) / reference_area_m2
132 }
133
134 pub fn moment_slope_m(&self, reference_area_m2: f64) -> f64 {
137 2.0 * (self.length_m * self.aft_area_m2 - self.volume_m3) / reference_area_m2
138 }
139
140 pub fn center_of_pressure_m(&self) -> Option<f64> {
144 let delta = self.aft_area_m2 - self.fore_area_m2;
145 (delta != 0.0).then(|| (self.length_m * self.aft_area_m2 - self.volume_m3) / delta)
146 }
147
148 pub fn lift_coefficient(&self, reference_area_m2: f64, alpha_rad: f64, factor: f64) -> f64 {
154 let s = alpha_rad.sin();
155 factor * self.planform_area_m2 / reference_area_m2 * s * s
156 }
157}
158
159pub(crate) fn sinc(x: f64) -> f64 {
161 if x.abs() < 1e-4 {
162 1.0 - x * x / 6.0
163 } else {
164 x.sin() / x
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use hpr_design::NoseShape;
171
172 use super::*;
173
174 fn close(got: f64, want: f64, rel: f64, what: &str) {
175 let err = ((got - want) / want).abs();
176 assert!(
177 err <= rel,
178 "{what}: got {got}, want {want}, rel err {err:e}"
179 );
180 }
181
182 fn from_profile(profile: &Profile) -> BodyGeometry {
183 BodyGeometry::from_profile(profile).unwrap()
184 }
185
186 fn frustum_cp(length: f64, fore_radius: f64, aft_radius: f64) -> f64 {
189 length / 3.0 * (1.0 + 1.0 / (1.0 + fore_radius / aft_radius))
190 }
191
192 #[test]
195 fn ogive_transition_cp_uses_volume_form() {
196 let (length, fore, aft) = (0.12, 0.02, 0.04);
197 let ogive = from_profile(
198 &Profile::transition(
199 NoseShape::Ogive { radius_ratio: 1.0 },
200 length,
201 fore,
202 aft,
203 false,
204 )
205 .unwrap(),
206 );
207 let cone = from_profile(
208 &Profile::transition(NoseShape::Conical {}, length, fore, aft, false).unwrap(),
209 );
210 close(
212 cone.center_of_pressure_m().unwrap(),
213 frustum_cp(length, fore, aft),
214 1e-11,
215 "conical transition CP",
216 );
217 let profile = Profile::transition(
220 NoseShape::Ogive { radius_ratio: 1.0 },
221 length,
222 fore,
223 aft,
224 false,
225 )
226 .unwrap();
227 let n = 20_000;
228 let h = length / f64::from(n);
229 let volume: f64 = (0..=n)
230 .map(|i| {
231 let r = profile.radius_m(f64::from(i) * h);
232 let w = if i == 0 || i == n {
233 1.0
234 } else if i % 2 == 1 {
235 4.0
236 } else {
237 2.0
238 };
239 w * PI * r * r
240 })
241 .sum::<f64>()
242 * h
243 / 3.0;
244 let want = (length * PI * aft * aft - volume) / (PI * (aft * aft - fore * fore));
245 close(
246 ogive.center_of_pressure_m().unwrap(),
247 want,
248 1e-9,
249 "ogive transition CP",
250 );
251 let gap = ogive.center_of_pressure_m().unwrap() - frustum_cp(length, fore, aft);
253 assert!(gap.abs() > 1e-3 * length, "gap {gap}");
254 assert_eq!(ogive.normal_force_slope(1.0), cone.normal_force_slope(1.0));
256 }
257
258 #[test]
262 fn potential_flow_terms_at_their_limits() {
263 let cone = from_profile(&Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap());
264 let a_ref = PI * 0.05 * 0.05;
265 close(cone.normal_force_slope(a_ref), 2.0, 1e-15, "cone slope");
266 close(cone.center_of_pressure_m().unwrap(), 0.2, 1e-11, "cone CP");
267 close(cone.moment_slope_m(a_ref), 0.4, 1e-11, "cone moment");
268
269 let tube = BodyGeometry::cylinder(0.5, 0.05).unwrap();
270 assert_eq!(tube.normal_force_slope(a_ref), 0.0);
271 assert_eq!(tube.center_of_pressure_m(), None);
272 assert!(tube.moment_slope_m(a_ref).abs() < 1e-15);
273
274 let boattail = from_profile(
275 &Profile::transition(NoseShape::Conical {}, 0.1, 0.05, 0.03, false).unwrap(),
276 );
277 close(
278 boattail.normal_force_slope(a_ref),
279 2.0 * (0.36 - 1.0),
280 1e-14,
281 "boattail slope",
282 );
283 close(
284 boattail.center_of_pressure_m().unwrap(),
285 frustum_cp(0.1, 0.05, 0.03),
286 1e-11,
287 "boattail CP",
288 );
289
290 let thin = from_profile(
293 &Profile::transition(NoseShape::Conical {}, 0.1, 0.05, 0.050_001, false).unwrap(),
294 );
295 assert!(thin.normal_force_slope(a_ref) > 0.0);
296 close(thin.center_of_pressure_m().unwrap(), 0.05, 1e-4, "thin CP");
297 assert!(thin.moment_slope_m(a_ref).abs() < 1e-5);
298 }
299
300 #[test]
303 fn body_lift_limits() {
304 let tube = BodyGeometry::cylinder(0.8, 0.04).unwrap();
305 let a_ref = PI * 0.04 * 0.04;
306 assert_eq!(tube.lift_coefficient(a_ref, 0.0, BODY_LIFT_K), 0.0);
307 let broadside = BODY_LIFT_K * 2.0 * 0.04 * 0.8 / a_ref;
308 close(
309 tube.lift_coefficient(a_ref, std::f64::consts::FRAC_PI_2, BODY_LIFT_K),
310 broadside,
311 1e-15,
312 "broadside",
313 );
314 close(
315 tube.lift_coefficient(a_ref, 0.1, BODY_LIFT_K),
316 tube.lift_coefficient(a_ref, -0.1, BODY_LIFT_K),
317 1e-15,
318 "symmetric",
319 );
320 close(
321 tube.lift_coefficient(a_ref, 0.01, BODY_LIFT_K),
322 broadside * 0.01f64.sin().powi(2),
323 1e-15,
324 "small angle",
325 );
326 assert_eq!(tube.planform_centroid_m, 0.4);
327 let cone = from_profile(&Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap());
329 close(
330 cone.planform_area_m2,
331 0.5 * 0.3 * 0.1,
332 1e-12,
333 "cone planform",
334 );
335 close(
336 cone.planform_centroid_m,
337 0.2,
338 1e-12,
339 "cone planform centroid",
340 );
341 }
342
343 #[test]
344 fn sinc_is_continuous() {
345 assert_eq!(sinc(0.0), 1.0);
346 for x in [1e-5, 9.9e-5, 1e-4, 1.01e-4] {
347 close(sinc(x), x.sin() / x, 1e-15, "sinc");
348 }
349 }
350
351 #[test]
352 fn bad_geometry_is_refused() {
353 assert!(BodyGeometry::cylinder(0.0, 0.1).is_err());
354 assert!(BodyGeometry::cylinder(1.0, f64::NAN).is_err());
355 let mut g = BodyGeometry::cylinder(1.0, 0.1).unwrap();
356 g.volume_m3 = -1.0;
357 assert!(g.validate().is_err());
358 }
359}