1use std::f64::consts::PI;
45
46use hpr_core::quadrature::{Tolerance, integrate};
47use serde::{Deserialize, Serialize};
48
49use crate::error::DesignError;
50use crate::shapes::{Profile, check_dimension};
51
52#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
55pub enum Wall {
56 Filled {},
58 Shell {
64 thickness_m: f64,
66 },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
72pub struct RevolvedGeometry {
73 pub volume_m3: f64,
75 pub centroid_m: f64,
77 pub axial_m5: f64,
79 pub transverse_m5: f64,
81 pub wetted_area_m2: f64,
83 pub planform_area_m2: f64,
85 pub planform_centroid_m: f64,
87}
88
89const SMOOTH: Tolerance = Tolerance {
91 relative: 1e-12,
92 absolute: 1e-14,
93 max_intervals: 4000,
94};
95
96const WALL: Tolerance = Tolerance {
98 relative: 1e-11,
99 absolute: 1e-13,
100 max_intervals: 4000,
101};
102
103pub fn revolve(profile: &Profile, wall: Wall) -> Result<RevolvedGeometry, DesignError> {
110 let length = profile.length_m();
111 let scale = profile.max_radius_m();
112 let thickness = match wall {
113 Wall::Filled {} => None,
114 Wall::Shell { thickness_m } => {
115 check_dimension("wall thickness", thickness_m, true)?;
116 Some(thickness_m)
117 }
118 };
119 let halves = |f: &dyn Fn(f64, f64, f64) -> [f64; 5]| -> Result<[f64; 5], DesignError> {
123 let mut total = [0.0; 5];
124 for from_aft in [false, true] {
125 let piece = integrate(
126 |s| {
127 let u = s * s;
128 let (r, slope) = profile.at_distance(u * length, from_aft);
129 let x = if from_aft { 1.0 - u } else { u };
130 f(x, r / scale, slope).map(|v| 2.0 * s * v)
131 },
132 0.0,
133 std::f64::consts::FRAC_1_SQRT_2,
134 SMOOTH,
135 )?;
136 for (sum, value) in total.iter_mut().zip(piece.value) {
137 *sum += value;
138 }
139 }
140 Ok(total)
141 };
142
143 let [wetted, planform, planform_moment, _, _] = halves(&|x, y, slope| {
145 let wetted = if y == 0.0 { 0.0 } else { y.hypot(y * slope) };
147 [wetted, y, x * y, 0.0, 0.0]
148 })?;
149 let [a, b, c, d, _] = halves(&|x, y, _| {
150 let y2 = y * y;
151 [y2, x * y2, y2 * y2, x * x * y2, 0.0]
152 })?;
153 let filled = [a, b, c, d];
154
155 let moments = match thickness {
157 None => filled,
158 Some(0.0) => [0.0; 4],
161 Some(t) => {
162 let inner = |x: f64| inner_radius(profile, x * length, t).0 / scale;
163 let integrand = |x: f64| {
164 let yi2 = inner(x).powi(2);
165 [yi2, x * yi2, yi2 * yi2, x * x * yi2]
166 };
167 let mut hollow = [0.0; 4];
168 let breaks = envelope_breaks(&|x: f64| {
171 let (r, source) = inner_radius(profile, x * length, t);
172 (r > 0.0, source)
173 });
174 for pair in breaks.windows(2) {
175 let piece = integrate(integrand, pair[0], pair[1], WALL)?;
179 for (sum, value) in hollow.iter_mut().zip(piece.value) {
180 *sum += value;
181 }
182 }
183 std::array::from_fn(|k| filled[k] - hollow[k])
184 }
185 };
186 let [area, first, fourth, second] = moments;
187 let r2l = scale * scale * length;
188 let volume = PI * r2l * area;
189 let centroid = if area > 0.0 {
190 length * first / area
191 } else {
192 0.5 * length
193 };
194 let axial = 0.5 * PI * r2l * scale * scale * fourth;
195 let about_fore = PI * (0.25 * r2l * scale * scale * fourth + r2l * length * length * second);
196 let transverse = about_fore - volume * centroid * centroid;
197 if transverse < -1e-9 * about_fore {
199 return Err(DesignError::Geometry(format!(
200 "the transverse moment came out negative ({transverse:e} m⁵)"
201 )));
202 }
203 Ok(RevolvedGeometry {
204 volume_m3: volume,
205 centroid_m: centroid,
206 axial_m5: axial,
207 transverse_m5: transverse.max(0.0),
208 wetted_area_m2: 2.0 * PI * scale * length * wetted,
209 planform_area_m2: 2.0 * scale * length * planform,
210 planform_centroid_m: if planform > 0.0 {
211 length * planform_moment / planform
212 } else {
213 0.5 * length
214 },
215 })
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220enum Source {
221 Surface,
223 Fore,
225 Aft,
227}
228
229fn inner_radius(profile: &Profile, x: f64, t: f64) -> (f64, Source) {
233 let length = profile.length_m();
234 let bound = |s: f64| -> f64 {
235 let dx = x - s;
236 profile.radius_m(s) - (t * t - dx * dx).max(0.0).sqrt()
237 };
238 let lo = (x - t).max(0.0);
240 let hi = (x + t).min(length);
241 const SAMPLES: usize = 32;
243 let step = (hi - lo) / SAMPLES as f64;
244 let sample = |k: usize| lo + step * (k as f64 + 0.5);
245 let best = (0..SAMPLES)
246 .min_by(|&i, &j| bound(sample(i)).total_cmp(&bound(sample(j))))
247 .unwrap_or(0);
248 let mut left = (sample(best) - step).max(lo);
249 let mut right = (sample(best) + step).min(hi);
250 let ratio = 0.5 * (5f64.sqrt() - 1.0);
251 let mut a = right - ratio * (right - left);
252 let mut b = left + ratio * (right - left);
253 let (mut fa, mut fb) = (bound(a), bound(b));
254 for _ in 0..80 {
255 if fa <= fb {
256 right = b;
257 b = a;
258 fb = fa;
259 a = right - ratio * (right - left);
260 fa = bound(a);
261 } else {
262 left = a;
263 a = b;
264 fa = fb;
265 b = left + ratio * (right - left);
266 fb = bound(b);
267 }
268 if right - left <= 1e-9 * t {
271 break;
272 }
273 }
274 let mut minimum = (fa.min(fb).min(bound(sample(best))), Source::Surface);
275 for (end, source) in [(0.0, Source::Fore), (length, Source::Aft)] {
278 if (x - end).abs() <= t {
279 let value = bound(end);
280 if value < minimum.0 {
281 minimum = (value, source);
282 }
283 }
284 }
285 (minimum.0.max(0.0), minimum.1)
286}
287
288fn envelope_breaks(state: &impl Fn(f64) -> (bool, Source)) -> Vec<f64> {
292 const GRID: usize = 128;
293 let mut points = vec![0.0];
294 let mut previous = state(0.0);
295 for k in 1..=GRID {
296 let x = k as f64 / GRID as f64;
297 let now = state(x);
298 if now != previous {
299 let (mut lo, mut hi) = ((k - 1) as f64 / GRID as f64, x);
300 for _ in 0..60 {
301 let mid = 0.5 * (lo + hi);
302 if state(mid) == previous {
303 lo = mid;
304 } else {
305 hi = mid;
306 }
307 }
308 points.push(0.5 * (lo + hi));
309 }
310 previous = now;
311 }
312 points.push(1.0);
313 points
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::shapes::NoseShape;
320
321 fn close(got: f64, want: f64, rel: f64, what: &str) {
322 let err = ((got - want) / want).abs();
323 assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
324 }
325
326 #[derive(serde::Deserialize)]
327 struct Fixture {
328 cases: Vec<Case>,
329 }
330
331 #[derive(serde::Deserialize)]
332 struct Case {
333 input: serde_json::Value,
334 expected: RevolvedGeometry,
335 }
336
337 #[test]
340 fn filled_solids_match_the_mpmath_references() {
341 let text = include_str!("../../../validation/fixtures/design/shape-integrals.json");
342 let fixture: Fixture = serde_json::from_str(text).unwrap();
343 assert_eq!(fixture.cases.len(), 22);
344 for case in fixture.cases {
345 let profile = fixture_profile(&case.input);
346 let got = revolve(&profile, Wall::Filled {}).unwrap();
347 let want = case.expected;
348 let label = case.input.to_string();
349 close(
350 got.volume_m3,
351 want.volume_m3,
352 1e-12,
353 &format!("volume {label}"),
354 );
355 close(
356 got.centroid_m,
357 want.centroid_m,
358 1e-12,
359 &format!("centroid {label}"),
360 );
361 close(
362 got.axial_m5,
363 want.axial_m5,
364 1e-12,
365 &format!("axial {label}"),
366 );
367 close(
368 got.transverse_m5,
369 want.transverse_m5,
370 1e-12,
371 &format!("transverse {label}"),
372 );
373 close(
374 got.wetted_area_m2,
375 want.wetted_area_m2,
376 1e-12,
377 &format!("wetted {label}"),
378 );
379 close(
380 got.planform_area_m2,
381 want.planform_area_m2,
382 1e-12,
383 &format!("planform {label}"),
384 );
385 close(
386 got.planform_centroid_m,
387 want.planform_centroid_m,
388 1e-12,
389 &format!("planform centroid {label}"),
390 );
391 }
392 }
393
394 #[derive(serde::Deserialize)]
395 struct WallFixture {
396 cases: Vec<WallCase>,
397 }
398
399 #[derive(serde::Deserialize)]
400 struct WallCase {
401 input: serde_json::Value,
402 wall_thickness_m: f64,
403 expected: WallExpected,
404 }
405
406 #[derive(serde::Deserialize)]
407 struct WallExpected {
408 volume_m3: f64,
409 centroid_m: f64,
410 axial_m5: f64,
411 transverse_m5: f64,
412 }
413
414 fn fixture_profile(input: &serde_json::Value) -> Profile {
416 let mut input = input.clone();
417 let object = input.as_object_mut().unwrap();
418 let kind = object.remove("shape").unwrap();
419 let mut shape = serde_json::json!({ "kind": kind });
420 if let Some(parameter) = object.remove("parameter") {
421 let key = match kind.as_str().unwrap() {
422 "ogive" => "radius_ratio",
423 "power_series" => "exponent",
424 _ => "parameter",
425 };
426 shape[key] = parameter;
427 }
428 object.insert("shape".to_owned(), shape);
429 serde_json::from_value(input).unwrap()
430 }
431
432 #[test]
436 fn walls_match_the_mpmath_references() {
437 let text = include_str!("../../../validation/fixtures/design/wall-integrals.json");
438 let fixture: WallFixture = serde_json::from_str(text).unwrap();
439 assert_eq!(fixture.cases.len(), 20);
440 for case in fixture.cases {
441 let profile = fixture_profile(&case.input);
442 let wall = Wall::Shell {
443 thickness_m: case.wall_thickness_m,
444 };
445 let got = revolve(&profile, wall).unwrap();
446 let want = case.expected;
447 let label = format!("{} t = {}", case.input, case.wall_thickness_m);
448 close(
449 got.volume_m3,
450 want.volume_m3,
451 1e-10,
452 &format!("volume {label}"),
453 );
454 close(
455 got.centroid_m,
456 want.centroid_m,
457 1e-10,
458 &format!("centroid {label}"),
459 );
460 close(
461 got.axial_m5,
462 want.axial_m5,
463 1e-10,
464 &format!("axial {label}"),
465 );
466 close(
467 got.transverse_m5,
468 want.transverse_m5,
469 1e-10,
470 &format!("transverse {label}"),
471 );
472 }
473 }
474
475 #[test]
476 fn extreme_parameters_stay_accurate_or_fail_loudly() {
477 let cone = revolve(
479 &Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap(),
480 Wall::Filled {},
481 )
482 .unwrap();
483 for ratio in [1e6, 1e12] {
484 let profile = Profile::nose(
485 NoseShape::Ogive {
486 radius_ratio: ratio,
487 },
488 0.3,
489 0.05,
490 )
491 .unwrap();
492 assert!(
493 (profile.radius_m(0.001) - 0.05 / 300.0).abs() < 1e-9,
494 "{ratio}"
495 );
496 let g = revolve(&profile, Wall::Filled {}).unwrap();
497 close(g.volume_m3, cone.volume_m3, 1e-5, &format!("ogive {ratio}"));
498 }
499 let n = crate::shapes::MIN_POWER_EXPONENT;
503 let shape = NoseShape::PowerSeries { exponent: n };
504 let g = revolve(&Profile::nose(shape, 0.2, 0.05).unwrap(), Wall::Filled {}).unwrap();
505 close(
506 g.volume_m3,
507 PI * 0.05 * 0.05 * 0.2 / (2.0 * n + 1.0),
508 1e-10,
509 "blunt power nose",
510 );
511 assert!(g.wetted_area_m2.is_finite());
512 let (r1, dr, l): (f64, f64, f64) = (0.0381, 0.0127, 0.1);
513 let volume = PI * l * (r1 * r1 + 2.0 * r1 * dr / (n + 1.0) + dr * dr / (2.0 * n + 1.0));
514 for (fore, aft) in [(r1, r1 + dr), (r1 + dr, r1)] {
515 let profile = Profile::transition(shape, l, fore, aft, false).unwrap();
516 let g = revolve(&profile, Wall::Filled {}).unwrap();
517 close(g.volume_m3, volume, 1e-10, "blunt power transition");
518 assert!(g.wetted_area_m2.is_finite());
519 revolve(&profile, Wall::Shell { thickness_m: 0.002 }).unwrap();
520 }
521 }
522
523 #[test]
524 fn a_tube_matches_the_hollow_cylinder_formulas() {
525 let (l, r, t) = (0.5, 0.05, 0.002);
526 let profile = Profile::transition(NoseShape::Conical {}, l, r, r, false).unwrap();
527 let g = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
528 let ri = r - t;
529 let v = PI * (r * r - ri * ri) * l;
530 close(g.volume_m3, v, 1e-12, "volume");
531 close(g.centroid_m, l / 2.0, 1e-12, "centroid");
532 close(g.axial_m5, 0.5 * v * (r * r + ri * ri), 1e-11, "axial");
533 close(
534 g.transverse_m5,
535 v * ((r * r + ri * ri) / 4.0 + l * l / 12.0),
536 1e-11,
537 "transverse",
538 );
539 close(g.wetted_area_m2, 2.0 * PI * r * l, 1e-12, "wetted");
540 close(g.planform_area_m2, 2.0 * r * l, 1e-12, "planform");
541 }
542
543 #[test]
544 fn a_conical_wall_is_the_cone_minus_its_offset_cone() {
545 let (l, r, t): (f64, f64, f64) = (0.3, 0.05, 0.003);
548 let k = r / l;
549 let x0 = t * (1.0 + k * k).sqrt() / k;
550 let profile = Profile::nose(NoseShape::Conical {}, l, r).unwrap();
551 let g = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
552 let u = l - x0;
554 let v_out = PI * r * r * l / 3.0;
555 let v_in = PI * k * k * u.powi(3) / 3.0;
556 let m_out = PI * k * k * l.powi(4) / 4.0;
557 let m_in = PI * k * k * (u.powi(4) / 4.0 + x0 * u.powi(3) / 3.0);
558 let volume = v_out - v_in;
559 close(g.volume_m3, volume, 1e-10, "volume");
560 close(g.centroid_m, (m_out - m_in) / volume, 1e-10, "centroid");
561 let a_out = 0.5 * PI * k.powi(4) * l.powi(5) / 5.0;
563 let a_in = 0.5 * PI * k.powi(4) * u.powi(5) / 5.0;
564 close(g.axial_m5, a_out - a_in, 1e-10, "axial");
565 let s_out = PI * k * k * l.powi(5) / 5.0;
567 let s_in =
568 PI * k * k * (u.powi(5) / 5.0 + 2.0 * x0 * u.powi(4) / 4.0 + x0 * x0 * u.powi(3) / 3.0);
569 let transverse =
570 0.5 * (a_out - a_in) + (s_out - s_in) - volume * ((m_out - m_in) / volume).powi(2);
571 close(g.transverse_m5, transverse, 1e-9, "transverse");
572 close(
573 g.wetted_area_m2,
574 PI * r * (r * r + l * l).sqrt(),
575 1e-12,
576 "wetted",
577 );
578 }
579
580 #[test]
581 fn a_tangent_ogive_wall_is_bounded_by_the_concentric_arc() {
582 let (l, r, t): (f64, f64, f64) = (0.25, 0.04, 0.002);
584 let rho = (r * r + l * l) / (2.0 * r);
585 let (a, c) = (rho - t, r - rho);
586 let u0 = (a * a - c * c).sqrt();
587 let hollow = PI
589 * ((a * a + c * c) * u0 - u0.powi(3) / 3.0
590 + c * (u0 * (a * a - u0 * u0).sqrt() + a * a * (u0 / a).asin()));
591 let outer =
592 PI * (l * rho * rho - l.powi(3) / 3.0 - (rho - r) * rho * rho * (l / rho).asin());
593 let profile = Profile::nose(NoseShape::TANGENT_OGIVE, l, r).unwrap();
594 let wall = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
595 let filled = revolve(&profile, Wall::Filled {}).unwrap();
596 close(filled.volume_m3, outer, 1e-12, "filled volume");
597 close(wall.volume_m3, outer - hollow, 1e-10, "wall volume");
598 }
599
600 #[test]
601 fn wall_mass_is_continuous_as_an_end_turns_vertical() {
602 for length in [0.005, 0.01, 0.05] {
607 let wall = |shape| {
608 let profile = Profile::transition(shape, length, 0.0381, 0.0508, false).unwrap();
609 let filled = revolve(&profile, Wall::Filled {}).unwrap().volume_m3;
610 let shell = revolve(&profile, Wall::Shell { thickness_m: 0.002 })
611 .unwrap()
612 .volume_m3;
613 (filled, shell)
614 };
615 let (cone_filled, cone_wall) = wall(NoseShape::Conical {});
616 let (power_filled, power_wall) = wall(NoseShape::PowerSeries { exponent: 0.99999 });
617 let filled_change = ((power_filled - cone_filled) / cone_filled).abs();
618 let wall_change = ((power_wall - cone_wall) / cone_wall).abs();
619 assert!(
620 wall_change < 1e-5,
621 "{length}: filled {filled_change:e}, wall {wall_change:e}"
622 );
623 }
624 }
625
626 #[test]
627 fn a_nearly_filled_cone_matches_the_offset_cone() {
628 let (l, r, t): (f64, f64, f64) = (0.3, 0.05, 0.0487);
631 let k = r / l;
632 let x0 = t * (1.0 + k * k).sqrt() / k;
633 assert!(l - x0 < l / 64.0);
634 let hollow = PI * k * k * (l - x0).powi(3) / 3.0;
635 let g = revolve(
636 &Profile::nose(NoseShape::Conical {}, l, r).unwrap(),
637 Wall::Shell { thickness_m: t },
638 )
639 .unwrap();
640 close(
641 g.volume_m3,
642 PI * r * r * l / 3.0 - hollow,
643 1e-12,
644 "thick cone",
645 );
646 assert!(hollow > 1e-9);
647 }
648
649 #[test]
650 fn a_thick_wall_fills_the_solid_and_a_thin_one_is_area_times_thickness() {
651 let profile = Profile::nose(NoseShape::VON_KARMAN, 0.3, 0.05).unwrap();
652 let filled = revolve(&profile, Wall::Filled {}).unwrap();
653 let thick = revolve(&profile, Wall::Shell { thickness_m: 0.2 }).unwrap();
654 close(thick.volume_m3, filled.volume_m3, 1e-12, "thick wall");
655 close(
656 thick.transverse_m5,
657 filled.transverse_m5,
658 1e-12,
659 "thick wall inertia",
660 );
661 let t = 1e-5;
663 let thin = revolve(&profile, Wall::Shell { thickness_m: t }).unwrap();
664 close(thin.volume_m3, filled.wetted_area_m2 * t, 2e-3, "thin wall");
665 assert!(revolve(&profile, Wall::Shell { thickness_m: -1.0 }).is_err());
666 let none = revolve(&profile, Wall::Shell { thickness_m: 0.0 }).unwrap();
668 assert_eq!(
669 (none.volume_m3, none.axial_m5, none.transverse_m5),
670 (0.0, 0.0, 0.0)
671 );
672 assert_eq!(none.wetted_area_m2, filled.wetted_area_m2);
673 assert_eq!(none.planform_area_m2, filled.planform_area_m2);
674 assert!(none.centroid_m.is_finite());
675 }
676}